bv-ui-core 2.9.1 → 2.9.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -7,23 +7,68 @@ The BvFetch module provides methods to cache duplicate API calls and interact wi
7
7
 
8
8
  ## BvFetch Parameters
9
9
  `shouldCache (Function):` A function that takes the API response JSON as input and returns a boolean indicating whether to cache the response or not. This allows you to implement custom logic based on the response content. If caching is desired, the function should return true; otherwise, false.
10
-
11
10
  `cacheName (String):` Optional. Specifies the name of the cache to be used. If not provided, the default cache name 'bvCache' will be used.
11
+ `cacheLimit (Integer)`: Optional. Specifies the cache size limit for the cache storage. Its value should be in MB. Default value is 10 MB.
12
12
 
13
13
  ## bvFetchFunc Method Parameters
14
14
  `url (String):` The URL of the API endpoint to fetch data from.
15
-
16
15
  `options (Object):` Optional request options such as headers, method, etc., as supported by the Fetch API.
17
16
 
18
17
  ## bvFetchFunc Return Value
19
18
  `Promise<Response>:` A promise that resolves to the API response. If the response is cached, it returns the cached response. Otherwise, it fetches data from the API endpoint, caches the response according to the caching logic, and returns the fetched response.
20
19
 
21
- ## flushCache Method Parameters
20
+ ## generateCacheKey Method Parameters:
21
+ `url (String):` The URL of the API endpoint.
22
+ `options (Object):` Optional request options.
23
+ ## generateCacheKey Return Value:
24
+ `string:` The generated cache key.
25
+
26
+ ## retrieveCachedUrls Method
27
+ Retrieves cached URLs from the cache storage associated with the provided cache name.
28
+ ## retrieveCachedUrls Parameters
22
29
  This method takes no parameters.
30
+ ## retrieveCachedUrls Return Value
31
+ `void:` This method does not return anything.
32
+
33
+ ## fetchDataAndCache Method
34
+ Fetches data from the specified URL, caches the response, and returns the response.
35
+ ## Parameters
36
+ `url (String):` The URL from which to fetch data.
37
+ `options (Object):` Optional request options such as headers, method, etc., as supported by the
38
+ Fetch API.
39
+ `cacheKey (String):`
40
+ The cache key associated with the fetched data.
41
+ ## Return Value
42
+ `Promise<Response>:` A promise that resolves to the fetched response.
43
+
44
+ ## fetchFromCache Method
45
+ Function to fetch data from cache.
46
+ ## Parameters
47
+ `cacheKey (String):` The cache key to fetch data from the cache.
48
+ ## Return Value
49
+ Promise<Response|null>: A Promise that resolves with a Response object if the data is found in cache, or null if the data is not cached or expired.
23
50
 
51
+ ## cacheData Method
52
+ Caches the provided response with the specified cache key if it meets the criteria for caching.
53
+ ## Parameters
54
+ `response (Response):` The response object to be cached.
55
+ `cacheKey (String):` The cache key associated with the response.
56
+ ## Return Value
57
+ `void:` This method does not return anything.
58
+
59
+
60
+ ## flushCache Method Parameters
61
+ This method takes no parameters.
24
62
  ## flushCache Return Value
25
63
  `Promise<void>:` A promise indicating the completion of cache flush operation.
26
64
 
65
+ ## manageCache Method
66
+ Manages the cache by deleting expired cache entries and maintaining the cache size limit.
67
+ ## Parameters
68
+ This method takes no parameters.
69
+ ## Return Value
70
+ `void:` This method does not return anything.
71
+
27
72
 
28
73
  ## Usage with of `BvFetch`:
29
74
 
@@ -6,10 +6,12 @@
6
6
 
7
7
  const { fetch } = require('../polyfills/fetch')
8
8
 
9
- module.exports = function BvFetch ({ shouldCache, cacheName }) {
9
+ module.exports = function BvFetch ({ shouldCache, cacheName, cacheLimit }) {
10
10
  this.shouldCache = shouldCache;
11
11
  this.cacheName = cacheName || 'bvCache';
12
+ this.cacheLimit = cacheLimit * 1024 * 1024 || 10 * 1024 * 1024;
12
13
  this.fetchPromises = new Map();
14
+ this.cachedUrls = new Set();
13
15
 
14
16
  /**
15
17
  * Generates a unique cache key for the given URL and options.
@@ -24,6 +26,129 @@ module.exports = function BvFetch ({ shouldCache, cacheName }) {
24
26
  return key;
25
27
  };
26
28
 
29
+ /**
30
+ * Retrieves cached URLs from the cache storage associated with the provided cache name.
31
+ * @returns {void}
32
+ */
33
+
34
+ this.retrieveCachedUrls = () => {
35
+ // Open the Cache Storage
36
+ caches.open(this.cacheName).then(cache => {
37
+ // Get all cache keys
38
+ cache.keys().then(keys => {
39
+ keys.forEach(request => {
40
+ this.cachedUrls.add(request.url);
41
+ });
42
+ });
43
+ });
44
+
45
+ }
46
+
47
+ //callretrieveCachedUrls function to set the cache URL set with the cached URLS
48
+ this.retrieveCachedUrls();
49
+
50
+ /**
51
+ * Fetches data from the specified URL, caches the response, and returns the response.
52
+ * @param {string} url - The URL from which to fetch data.
53
+ * @param {string} cacheKey - The cache key associated with the fetched data.
54
+ * @returns {Promise<Response>} A Promise that resolves with the fetched response.
55
+ * @throws {Error} Throws an error if there's any problem fetching the data.
56
+ */
57
+ this.fetchDataAndCache = (url, options = {}, cacheKey) => {
58
+ return fetch(url,options)
59
+ .then((response) => {
60
+ // initiate caching of response and return the response
61
+ this.cacheData(response, cacheKey);
62
+ return response.clone();
63
+ })
64
+ .catch(function (error) {
65
+ throw new Error('Error fetching data: ' + error);
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Caches the provided response with the specified cache key if it meets the criteria for caching.
71
+ * @param {Response} response - The response object to be cached.
72
+ * @param {string} cacheKey - The cache key associated with the response.
73
+ * @returns {void}
74
+ */
75
+
76
+ this.cacheData = (response, cacheKey) => {
77
+ const errJson = response.clone();
78
+ let canBeCached = true;
79
+ // Check for error in response obj
80
+ errJson.json().then(json => {
81
+ if (typeof this.shouldCache === 'function') {
82
+ canBeCached = this.shouldCache(json);
83
+ }
84
+ }).then(() => {
85
+ if (canBeCached) {
86
+ const clonedResponse = response.clone();
87
+ const sizeCheck = response.clone();
88
+ const newHeaders = new Headers();
89
+ clonedResponse.headers.forEach((value, key) => {
90
+ newHeaders.append(key, value);
91
+ });
92
+ newHeaders.append('X-Bazaarvoice-Cached-Time', Date.now())
93
+ // Get response text to calculate its size
94
+ sizeCheck.text().then(text => {
95
+ // Calculate size of response text in bytes
96
+ const sizeInBytes = new Blob([text]).size;
97
+
98
+ // Append response size to headers
99
+ newHeaders.append('X-Bazaarvoice-Response-Size', sizeInBytes);
100
+
101
+ // Create new Response object with modified headers
102
+ const newResponse = new Response(clonedResponse._bodyBlob || clonedResponse.body, {
103
+ status: clonedResponse.status,
104
+ statusText: clonedResponse.statusText,
105
+ headers: newHeaders
106
+ });
107
+ // Cache the response
108
+ caches.open(this.cacheName).then(cache => {
109
+ cache.put(cacheKey, newResponse);
110
+ //add key to cachedUrls set
111
+ this.cachedUrls.add(cacheKey);
112
+ });
113
+ });
114
+ }
115
+ })
116
+ }
117
+
118
+ /**
119
+ * Function to fetch data from cache.
120
+ * @param {string} cacheKey - The cache key to fetch data from the cache.
121
+ * @returns {Promise<Response|null>} A Promise that resolves with a Response object if the data is found in cache,
122
+ * or null if the data is not cached or expired.
123
+ * @throws {Error} Throws an error if there's any problem fetching from cache.
124
+ */
125
+ this.fetchFromCache = (cacheKey) => {
126
+ // Check if the URL is in the set of cached URLs
127
+ if (!this.cachedUrls.has(cacheKey)) {
128
+ return Promise.resolve(null);
129
+ }
130
+
131
+ // Open the cache and try to match the URL
132
+ return caches.open(this.cacheName)
133
+ .then((cache) => {
134
+ return cache.match(cacheKey)
135
+ .then((cachedResponse) => {
136
+
137
+ const cachedTime = cachedResponse.headers.get('X-Bazaarvoice-Cached-Time');
138
+ const ttl = cachedResponse.headers.get('Cache-Control').match(/max-age=(\d+)/)[1];
139
+ const currentTimestamp = Date.now();
140
+ const cacheAge = (currentTimestamp - cachedTime) / 1000;
141
+ if (cacheAge < ttl) {
142
+ // Cached response found
143
+ return cachedResponse.clone();
144
+ }
145
+ })
146
+ })
147
+ .catch((error) => {
148
+ throw new Error('Error fetching from cache: ' + error);
149
+ });
150
+ }
151
+
27
152
  /**
28
153
  * Fetches data from the API endpoint, caches responses, and handles caching logic.
29
154
  * @param {string} url - The URL of the API endpoint.
@@ -32,84 +157,38 @@ module.exports = function BvFetch ({ shouldCache, cacheName }) {
32
157
  */
33
158
 
34
159
  this.bvFetchFunc = (url, options = {}) => {
35
- // get the key
160
+
36
161
  const cacheKey = this.generateCacheKey(url, options);
162
+ // If an ongoing fetch promise exists for the URL, return it
163
+ if (this.fetchPromises.has(cacheKey)) {
164
+ return this.fetchPromises.get(cacheKey).then(res => res.clone());
165
+ }
37
166
 
38
- // check if its available in the cache
39
- return caches.open(this.cacheName)
40
- .then(currentCache => currentCache.match(cacheKey))
41
- .then(cachedResponse => {
167
+ // Check if response is available in cache
168
+ const newPromise = this.fetchFromCache(cacheKey)
169
+ .then((cachedResponse) => {
170
+ // If response found in cache, return it
42
171
  if (cachedResponse) {
43
- const cachedTime = cachedResponse.headers.get('X-Cached-Time');
44
- const ttl = cachedResponse.headers.get('Cache-Control').match(/max-age=(\d+)/)[1];
45
- const currentTimestamp = Date.now();
46
- const cacheAge = (currentTimestamp - cachedTime) / 1000;
47
-
48
- if (cacheAge < ttl) {
49
- // Cached response found
50
- return cachedResponse.clone();
51
- }
172
+ return cachedResponse;
52
173
  }
174
+ // If response not found in cache, fetch from API and cache it
175
+ return this.fetchDataAndCache(url, options, cacheKey);
176
+ });
53
177
 
54
- // check if there is an ongoing promise
55
- if (this.fetchPromises.has(cacheKey)) {
56
- return this.fetchPromises.get(cacheKey).then(res => res.clone());
57
- }
178
+ // Store the ongoing fetch promise
179
+ this.fetchPromises.set(cacheKey, newPromise);
58
180
 
59
- // Make a new call
60
- const newPromise = fetch(url, options);
61
-
62
- // Push the newPromise to the fetchPromises Map
63
- this.fetchPromises.set(cacheKey, newPromise);
64
-
65
- return newPromise
66
- .then(response => {
67
- const clonedResponse = response.clone();
68
- const errJson = clonedResponse.clone()
69
- let canBeCached = true;
70
- return errJson.json().then(json => {
71
- if (typeof this.shouldCache === 'function') {
72
- canBeCached = this.shouldCache(json);
73
- }
74
- return response
75
- }).then(res => {
76
- if (canBeCached) {
77
- const newHeaders = new Headers();
78
- clonedResponse.headers.forEach((value, key) => {
79
- newHeaders.append(key, value);
80
- });
81
- newHeaders.append('X-Cached-Time', Date.now());
82
-
83
- const newResponse = new Response(clonedResponse._bodyBlob, {
84
- status: clonedResponse.status,
85
- statusText: clonedResponse.statusText,
86
- headers: newHeaders
87
- });
88
- //Delete promise from promise map once its resolved
89
- this.fetchPromises.delete(cacheKey);
90
-
91
- return caches.open(this.cacheName)
92
- .then(currentCache =>
93
- currentCache.put(cacheKey, newResponse)
94
- )
95
- .then(() => res);
96
- }
97
- else {
98
- //Delete promise from promise map if error exists
99
- this.fetchPromises.delete(cacheKey);
100
-
101
- return res
102
- }
181
+ //initiate cache cleanUp
182
+ this.debounceCleanupExpiredCache();
183
+
184
+ // When fetch completes or fails, remove the promise from the store
185
+ newPromise.finally(() => {
186
+ this.fetchPromises.delete(cacheKey);
187
+ });
188
+
189
+ return newPromise.then(res => res.clone());
190
+ }
103
191
 
104
- });
105
- })
106
- })
107
- .catch(err => {
108
- // Remove the promise that was pushed earlier
109
- this.fetchPromises.delete(cacheKey);
110
- throw err;
111
- });
112
- };
113
192
 
114
193
  /**
115
194
  * Clears all cache entries stored in the cache storage.
@@ -124,5 +203,89 @@ module.exports = function BvFetch ({ shouldCache, cacheName }) {
124
203
  });
125
204
  });
126
205
  };
206
+
207
+ this.manageCache = () => {
208
+ // Delete expired cache entries
209
+ caches.open(this.cacheName).then(cache => {
210
+ cache.keys().then(keys => {
211
+ keys.forEach(key => {
212
+ cache.match(key).then(response => {
213
+ const cachedTime = response.headers.get('X-Bazaarvoice-Cached-Time');
214
+ const ttl = response.headers.get('Cache-Control').match(/max-age=(\d+)/)[1];
215
+ const currentTimestamp = Date.now();
216
+ const cacheAge = (currentTimestamp - cachedTime) / 1000;
217
+ if (cacheAge >= ttl) {
218
+ cache.delete(key);
219
+ this.cachedUrls.delete(key);
220
+ }
221
+ });
222
+ });
223
+ });
224
+ });
225
+
226
+ // Calculate total size of cached responses
227
+ let totalSize = 0;
228
+ caches.open(this.cacheName).then(cache => {
229
+ cache.keys().then(keys => {
230
+ // Create an array of promises for cache match operations
231
+ const matchPromises = keys.map(key =>
232
+ cache.match(key).then(response => {
233
+ const sizeHeader = response.headers.get('X-Bazaarvoice-Response-Size');
234
+ return parseInt(sizeHeader, 10);
235
+ })
236
+ );
237
+
238
+ // wait for all match promises to resolve
239
+ return Promise.all(matchPromises)
240
+ .then(sizes => sizes.reduce((acc, size) => acc + size, 0));
241
+ }).then(size => {
242
+ totalSize = size;
243
+ // If total size exceeds 10 MB, delete old cache entries
244
+ if (totalSize > this.cacheLimit) {
245
+
246
+ // create an array of cached responses
247
+ const cacheEntries = [];
248
+ return cache.keys().then(keys => {
249
+ const cachesResEntries = keys.map(key =>
250
+ cache.match(key).then(response => {
251
+ const sizeHeader = response.headers.get('X-Bazaarvoice-Response-Size');
252
+ const lastAccessedTime = response.headers.get('X-Bazaarvoice-Cached-Time');
253
+ cacheEntries.push({ key, size: parseInt(sizeHeader, 10), lastAccessedTime });
254
+ })
255
+ );
256
+
257
+ return Promise.all(cachesResEntries)
258
+ .then(() => {
259
+ // Sort cache entries by last accessed time in ascending order
260
+ cacheEntries.sort((a, b) => a.lastAccessedTime - b.lastAccessedTime);
261
+
262
+ // Delete older cache entries until total size is under 10 MB
263
+ let currentSize = totalSize;
264
+ cacheEntries.forEach(entry => {
265
+ if (currentSize > this.cacheLimit) {
266
+ cache.delete(entry.key);
267
+ this.cachedUrls.delete(entry.key);
268
+ currentSize -= entry.size;
269
+ }
270
+ });
271
+ });
272
+ });
273
+ }
274
+ });
275
+ });
276
+ };
277
+
278
+
279
+ function debounce (func, delay) {
280
+ let timer;
281
+ return function () {
282
+ clearTimeout(timer);
283
+ timer = setTimeout(() => {
284
+ func.apply(this, arguments);
285
+ }, delay);
286
+ };
287
+ }
288
+
289
+ this.debounceCleanupExpiredCache = debounce(this.manageCache, 8000);
127
290
 
128
- }
291
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bv-ui-core",
3
- "version": "2.9.1",
3
+ "version": "2.9.3",
4
4
  "license": "Apache 2.0",
5
5
  "description": "Bazaarvoice UI-related JavaScript",
6
6
  "repository": {
@@ -65,7 +65,7 @@ describe('BvFetch', function () {
65
65
  caches.open.resolves({
66
66
  match: (key) => {
67
67
  expect(key).to.equal(cacheKey);
68
- Promise.resolve(mockResponse)
68
+ return Promise.resolve(mockResponse)
69
69
  },
70
70
  put: (key, response) => {
71
71
  cacheStorage.set(key, response);
@@ -73,6 +73,10 @@ describe('BvFetch', function () {
73
73
  }
74
74
  });
75
75
 
76
+ // Simulate that the response is cached
77
+ bvFetchInstance.cachedUrls.add(cacheKey);
78
+
79
+ // Call the function under test
76
80
  bvFetchInstance.bvFetchFunc(url, options)
77
81
  .then(response => {
78
82
  // Check if response is fetched from cache
@@ -91,19 +95,18 @@ describe('BvFetch', function () {
91
95
  done(error); // Call done with error if any
92
96
  })
93
97
  });
94
-
95
98
 
99
+
96
100
  it('should fetch from network when response is not cached', function (done) {
97
101
  const url = 'https://jsonplaceholder.typicode.com/todos';
98
102
  const options = {};
99
103
 
100
- const cacheKey = bvFetchInstance.generateCacheKey(url, options);
101
-
104
+ const matchSpy = sinon.spy((key) => {
105
+ expect(key).to.equal(cacheKey);
106
+ Promise.resolve(null)
107
+ });
102
108
  caches.open.resolves({
103
- match: (key) => {
104
- expect(key).to.equal(cacheKey);
105
- Promise.resolve(null)
106
- },
109
+ match: matchSpy,
107
110
  put: (key, response) => {
108
111
  cacheStorage.set(key, response);
109
112
  return Promise.resolve();
@@ -118,7 +121,7 @@ describe('BvFetch', function () {
118
121
  console.log(response.body)
119
122
 
120
123
  // Check if caches.match was called
121
- expect(cacheStub.called).to.be.true;
124
+ expect(matchSpy.called).to.be.false;
122
125
 
123
126
  done();
124
127
  })
@@ -133,24 +136,79 @@ describe('BvFetch', function () {
133
136
  bvFetchInstance.shouldCache = (res) => {
134
137
  return false
135
138
  };
136
-
139
+
137
140
  bvFetchInstance.bvFetchFunc(url, options)
138
141
  .then(response => {
139
142
  // Check if response is fetched from network
140
- expect(response).to.not.be.null;
141
- console.log(response.body)
143
+ setTimeout(() => {
144
+ expect(response).to.not.be.null;
145
+ console.log(response.body)
142
146
 
143
147
  // Check if caches.match was called
144
- expect(cacheStub.calledOnce).to.be.true;
148
+ expect(cacheStub.calledOnce).to.be.false;
145
149
 
146
150
  // Check if response is not cached
147
- const cachedResponse = cacheStorage.get(url);
148
- expect(cachedResponse).to.be.undefined;
149
-
151
+ const cachedResponse = cacheStorage.get(url);
152
+ expect(cachedResponse).to.be.undefined;
153
+ }, 500)
150
154
  done();
151
155
  })
152
156
  .catch(done);
153
157
  });
154
158
 
159
+ it('should delete cache when size is greater than 10 MB', function (done) {
160
+ // Mock cache entries exceeding 10 MB
161
+ const mockCacheEntries = [
162
+ { key: 'key1', size: 6000000 }, // 6 MB
163
+ { key: 'key2', size: 6000000 } // 6 MB
164
+ // Add more entries as needed to exceed 10 MB
165
+ ];
166
+
167
+ // Stub cache operations
168
+ const deleteSpy = sinon.spy(
169
+ (key) => {
170
+ const index = mockCacheEntries.findIndex(entry => entry.key === key);
171
+ if (index !== -1) {
172
+ mockCacheEntries.splice(index, 1); // Delete entry from mock cache entries
173
+ }
174
+ return Promise.resolve(true);
175
+ }
176
+ )
177
+ caches.open.resolves({
178
+ keys: () => Promise.resolve(mockCacheEntries.map(entry => entry.key)),
179
+ match: (key) => {
180
+ const entry = mockCacheEntries.find(entry => entry.key === key);
181
+ if (entry) {
182
+ return Promise.resolve({
183
+ headers: new Headers({
184
+ 'X-Bazaarvoice-Response-Size': entry.size.toString(),
185
+ 'X-Bazaarvoice-Cached-Time': Date.now(),
186
+ 'Cache-Control': 'max-age=3600'
187
+ })
188
+ });
189
+ }
190
+ else {
191
+ return Promise.resolve(null);
192
+ }
193
+ },
194
+ delete: deleteSpy
195
+ });
196
+
197
+ // Create a new instance of BvFetch
198
+ const bvFetchInstance = new BvFetch({ shouldCache: true });
199
+
200
+ // Call manageCache function
201
+ bvFetchInstance.manageCache()
202
+ setTimeout(() => {
203
+ // Ensure cache deletion occurred until the total size is under 10 MB
204
+ const totalSizeAfterDeletion = mockCacheEntries.reduce((acc, entry) => acc + entry.size, 0);
205
+ expect(totalSizeAfterDeletion).to.be.at.most(10 * 1024 * 1024); // Total size should be under 10 MB
206
+ // Ensure cache.delete was called for each deleted entry
207
+ expect(deleteSpy.called).to.be.true;
208
+ expect(deleteSpy.callCount).to.equal(mockCacheEntries.length);
209
+ done();
210
+ }, 500);
211
+
212
+ });
155
213
 
156
214
  });