@serwist/expiration 9.0.0-preview.14 → 9.0.0-preview.16

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.
@@ -1,4 +1,4 @@
1
- import type { SerwistPlugin } from "@serwist/core";
1
+ import type { CacheDidUpdateCallbackParam, CachedResponseWillBeUsedCallbackParam, SerwistPlugin } from "@serwist/core";
2
2
  export interface ExpirationPluginOptions {
3
3
  /**
4
4
  * The maximum number of entries to cache. Entries used the least will be removed
@@ -6,9 +6,16 @@ export interface ExpirationPluginOptions {
6
6
  */
7
7
  maxEntries?: number;
8
8
  /**
9
- * The maximum age of an entry before it's treated as stale and removed.
9
+ * The maximum number of seconds before an entry is treated as stale and removed.
10
10
  */
11
11
  maxAgeSeconds?: number;
12
+ /**
13
+ * Determines whether `maxAgeSeconds` should be calculated from when an
14
+ * entry was last fetched or when it was last used.
15
+ *
16
+ * @default "last-fetched"
17
+ */
18
+ maxAgeFrom?: "last-fetched" | "last-used";
12
19
  /**
13
20
  * The [`CacheQueryOptions`](https://developer.mozilla.org/en-US/docs/Web/API/Cache/delete#Parameters)
14
21
  * that will be used when calling `delete()` on the cache.
@@ -42,7 +49,6 @@ export interface ExpirationPluginOptions {
42
49
  */
43
50
  export declare class ExpirationPlugin implements SerwistPlugin {
44
51
  private readonly _config;
45
- private readonly _maxAgeSeconds?;
46
52
  private _cacheExpirations;
47
53
  /**
48
54
  * @param config
@@ -66,11 +72,11 @@ export declare class ExpirationPlugin implements SerwistPlugin {
66
72
  * older than the configured `maxAgeSeconds`.
67
73
  *
68
74
  * @param options
69
- * @returns Either the `cachedResponse`, if it's fresh, or `null` if the `Response`
70
- * is older than `maxAgeSeconds`.
75
+ * @returns `cachedResponse` if it is fresh and `null` if it is stale or
76
+ * not available.
71
77
  * @private
72
78
  */
73
- cachedResponseWillBeUsed: SerwistPlugin["cachedResponseWillBeUsed"];
79
+ cachedResponseWillBeUsed({ event, cacheName, request, cachedResponse }: CachedResponseWillBeUsedCallbackParam): Response | null;
74
80
  /**
75
81
  * @param cachedResponse
76
82
  * @returns
@@ -78,8 +84,7 @@ export declare class ExpirationPlugin implements SerwistPlugin {
78
84
  */
79
85
  private _isResponseDateFresh;
80
86
  /**
81
- * This method will extract the data header and parse it into a useful
82
- * value.
87
+ * Extracts the `Date` header and parse it into an useful value.
83
88
  *
84
89
  * @param cachedResponse
85
90
  * @returns
@@ -93,7 +98,7 @@ export declare class ExpirationPlugin implements SerwistPlugin {
93
98
  * @param options
94
99
  * @private
95
100
  */
96
- cacheDidUpdate: SerwistPlugin["cacheDidUpdate"];
101
+ cacheDidUpdate({ cacheName, request }: CacheDidUpdateCallbackParam): Promise<void>;
97
102
  /**
98
103
  * Deletes the underlying `Cache` instance associated with this instance and the metadata
99
104
  * from IndexedDB used to keep track of expiration details for each `Cache` instance.
@@ -1 +1 @@
1
- {"version":3,"file":"ExpirationPlugin.d.ts","sourceRoot":"","sources":["../src/ExpirationPlugin.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAMnD,MAAM,WAAW,uBAAuB;IACtC;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;OAEG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,YAAY,CAAC,EAAE,iBAAiB,CAAC;IACjC;;OAEG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,qBAAa,gBAAiB,YAAW,aAAa;IACpD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAClD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAS;IACzC,OAAO,CAAC,iBAAiB,CAA+B;IAExD;;OAEG;gBACS,MAAM,GAAE,uBAA4B;IAsChD;;;;;;;OAOG;IACH,OAAO,CAAC,mBAAmB;IAa3B;;;;;;;;;;;;OAYG;IACH,wBAAwB,EAAE,aAAa,CAAC,0BAA0B,CAAC,CA+BjE;IAEF;;;;OAIG;IACH,OAAO,CAAC,oBAAoB;IAqB5B;;;;;;;OAOG;IACH,OAAO,CAAC,uBAAuB;IAkB/B;;;;;;OAMG;IACH,cAAc,EAAE,aAAa,CAAC,gBAAgB,CAAC,CAmB7C;IAEF;;;;;;;;;;;OAWG;IACG,sBAAsB,IAAI,OAAO,CAAC,IAAI,CAAC;CAW9C"}
1
+ {"version":3,"file":"ExpirationPlugin.d.ts","sourceRoot":"","sources":["../src/ExpirationPlugin.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,2BAA2B,EAAE,qCAAqC,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAMvH,MAAM,WAAW,uBAAuB;IACtC;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;OAEG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,cAAc,GAAG,WAAW,CAAC;IAC1C;;;OAGG;IACH,YAAY,CAAC,EAAE,iBAAiB,CAAC;IACjC;;OAEG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,qBAAa,gBAAiB,YAAW,aAAa;IACpD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAClD,OAAO,CAAC,iBAAiB,CAA+B;IAExD;;OAEG;gBACS,MAAM,GAAE,uBAA4B;IAkDhD;;;;;;;OAOG;IACH,OAAO,CAAC,mBAAmB;IAa3B;;;;;;;;;;;;OAYG;IACH,wBAAwB,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,cAAc,EAAE,EAAE,qCAAqC;IAqC7G;;;;OAIG;IACH,OAAO,CAAC,oBAAoB;IAwB5B;;;;;;OAMG;IACH,OAAO,CAAC,uBAAuB;IAkB/B;;;;;;OAMG;IACG,cAAc,CAAC,EAAE,SAAS,EAAE,OAAO,EAAE,EAAE,2BAA2B;IAqBxE;;;;;;;;;;;OAWG;IACG,sBAAsB,IAAI,OAAO,CAAC,IAAI,CAAC;CAW9C"}
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { assert, SerwistError, logger, dontWaitFor, privateCacheNames, getFriendlyURL } from '@serwist/core/internal';
1
+ import { assert, SerwistError, logger, privateCacheNames, getFriendlyURL } from '@serwist/core/internal';
2
2
  import { deleteDB, openDB } from 'idb';
3
3
  import { registerQuotaErrorCallback } from '@serwist/core';
4
4
 
@@ -17,7 +17,10 @@ class CacheTimestampsModel {
17
17
  }
18
18
  _upgradeDb(db) {
19
19
  const objStore = db.createObjectStore(CACHE_OBJECT_STORE, {
20
- keyPath: "id"
20
+ keyPath: [
21
+ "url",
22
+ "cacheName"
23
+ ]
21
24
  });
22
25
  objStore.createIndex("cacheName", "cacheName", {
23
26
  unique: false
@@ -35,10 +38,9 @@ class CacheTimestampsModel {
35
38
  async setTimestamp(url, timestamp) {
36
39
  url = normalizeURL(url);
37
40
  const entry = {
38
- url,
39
- timestamp,
40
41
  cacheName: this._cacheName,
41
- id: this._getId(url)
42
+ url,
43
+ timestamp
42
44
  };
43
45
  const db = await this.getDb();
44
46
  const tx = db.transaction(CACHE_OBJECT_STORE, "readwrite", {
@@ -49,35 +51,31 @@ class CacheTimestampsModel {
49
51
  }
50
52
  async getTimestamp(url) {
51
53
  const db = await this.getDb();
52
- const entry = await db.get(CACHE_OBJECT_STORE, this._getId(url));
54
+ const entry = await db.get(CACHE_OBJECT_STORE, [
55
+ this._cacheName,
56
+ normalizeURL(url)
57
+ ]);
53
58
  return entry?.timestamp;
54
59
  }
55
60
  async expireEntries(minTimestamp, maxCount) {
56
61
  const db = await this.getDb();
57
- let cursor = await db.transaction(CACHE_OBJECT_STORE).store.index("timestamp").openCursor(null, "prev");
58
- const entriesToDelete = [];
62
+ let cursor = await db.transaction(CACHE_OBJECT_STORE, "readwrite").store.index("timestamp").openCursor(null, "prev");
63
+ const urlsDeleted = [];
59
64
  let entriesNotDeletedCount = 0;
60
65
  while(cursor){
61
66
  const result = cursor.value;
62
67
  if (result.cacheName === this._cacheName) {
63
68
  if (minTimestamp && result.timestamp < minTimestamp || maxCount && entriesNotDeletedCount >= maxCount) {
64
- entriesToDelete.push(cursor.value);
69
+ cursor.delete();
70
+ urlsDeleted.push(result.url);
65
71
  } else {
66
72
  entriesNotDeletedCount++;
67
73
  }
68
74
  }
69
75
  cursor = await cursor.continue();
70
76
  }
71
- const urlsDeleted = [];
72
- for (const entry of entriesToDelete){
73
- await db.delete(CACHE_OBJECT_STORE, entry.id);
74
- urlsDeleted.push(entry.url);
75
- }
76
77
  return urlsDeleted;
77
78
  }
78
- _getId(url) {
79
- return `${this._cacheName}|${normalizeURL(url)}`;
80
- }
81
79
  async getDb() {
82
80
  if (!this._db) {
83
81
  this._db = await openDB(DB_NAME, 1, {
@@ -161,7 +159,7 @@ class CacheExpiration {
161
159
  this._isRunning = false;
162
160
  if (this._rerunRequested) {
163
161
  this._rerunRequested = false;
164
- dontWaitFor(this.expireEntries());
162
+ void this.expireEntries();
165
163
  }
166
164
  }
167
165
  async updateTimestamp(url) {
@@ -191,27 +189,26 @@ class CacheExpiration {
191
189
  }
192
190
  async delete() {
193
191
  this._rerunRequested = false;
194
- await this._timestampModel.expireEntries(Infinity);
192
+ await this._timestampModel.expireEntries(Number.POSITIVE_INFINITY);
195
193
  }
196
194
  }
197
195
 
198
196
  class ExpirationPlugin {
199
197
  _config;
200
- _maxAgeSeconds;
201
198
  _cacheExpirations;
202
199
  constructor(config = {}){
203
200
  if (process.env.NODE_ENV !== "production") {
204
201
  if (!(config.maxEntries || config.maxAgeSeconds)) {
205
202
  throw new SerwistError("max-entries-or-age-required", {
206
203
  moduleName: "@serwist/expiration",
207
- className: "Plugin",
204
+ className: "ExpirationPlugin",
208
205
  funcName: "constructor"
209
206
  });
210
207
  }
211
208
  if (config.maxEntries) {
212
209
  assert.isType(config.maxEntries, "number", {
213
210
  moduleName: "@serwist/expiration",
214
- className: "Plugin",
211
+ className: "ExpirationPlugin",
215
212
  funcName: "constructor",
216
213
  paramName: "config.maxEntries"
217
214
  });
@@ -219,16 +216,26 @@ class ExpirationPlugin {
219
216
  if (config.maxAgeSeconds) {
220
217
  assert.isType(config.maxAgeSeconds, "number", {
221
218
  moduleName: "@serwist/expiration",
222
- className: "Plugin",
219
+ className: "ExpirationPlugin",
223
220
  funcName: "constructor",
224
221
  paramName: "config.maxAgeSeconds"
225
222
  });
226
223
  }
224
+ if (config.maxAgeFrom) {
225
+ assert.isType(config.maxAgeFrom, "string", {
226
+ moduleName: "@serwist/expiration",
227
+ className: "ExpirationPlugin",
228
+ funcName: "constructor",
229
+ paramName: "config.maxAgeFrom"
230
+ });
231
+ }
227
232
  }
228
233
  this._config = config;
229
- this._maxAgeSeconds = config.maxAgeSeconds;
230
234
  this._cacheExpirations = new Map();
231
- if (config.purgeOnQuotaError) {
235
+ if (!this._config.maxAgeFrom) {
236
+ this._config.maxAgeFrom = "last-fetched";
237
+ }
238
+ if (this._config.purgeOnQuotaError) {
232
239
  registerQuotaErrorCallback(()=>this.deleteCacheAndMetadata());
233
240
  }
234
241
  }
@@ -243,37 +250,44 @@ class ExpirationPlugin {
243
250
  }
244
251
  return cacheExpiration;
245
252
  }
246
- cachedResponseWillBeUsed = async ({ event, request, cacheName, cachedResponse })=>{
253
+ cachedResponseWillBeUsed({ event, cacheName, request, cachedResponse }) {
247
254
  if (!cachedResponse) {
248
255
  return null;
249
256
  }
250
257
  const isFresh = this._isResponseDateFresh(cachedResponse);
251
258
  const cacheExpiration = this._getCacheExpiration(cacheName);
252
- dontWaitFor(cacheExpiration.expireEntries());
253
- const updateTimestampDone = cacheExpiration.updateTimestamp(request.url);
254
- if (event) {
255
- try {
256
- event.waitUntil(updateTimestampDone);
257
- } catch (error) {
258
- if (process.env.NODE_ENV !== "production") {
259
- if ("request" in event) {
260
- logger.warn(`Unable to ensure service worker stays alive when updating cache entry for '${getFriendlyURL(event.request.url)}'.`);
261
- }
259
+ const isMaxAgeFromLastUsed = this._config.maxAgeFrom === "last-used";
260
+ const done = (async ()=>{
261
+ if (isMaxAgeFromLastUsed) {
262
+ await cacheExpiration.updateTimestamp(request.url);
263
+ }
264
+ await cacheExpiration.expireEntries();
265
+ })();
266
+ try {
267
+ event.waitUntil(done);
268
+ } catch (error) {
269
+ if (process.env.NODE_ENV !== "production") {
270
+ if (event instanceof FetchEvent) {
271
+ logger.warn(`Unable to ensure service worker stays alive when updating cache entry for '${getFriendlyURL(event.request.url)}'.`);
262
272
  }
263
273
  }
264
274
  }
265
275
  return isFresh ? cachedResponse : null;
266
- };
276
+ }
267
277
  _isResponseDateFresh(cachedResponse) {
268
- if (!this._maxAgeSeconds) {
278
+ const isMaxAgeFromLastUsed = this._config.maxAgeFrom === "last-used";
279
+ if (isMaxAgeFromLastUsed) {
280
+ return true;
281
+ }
282
+ const now = Date.now();
283
+ if (!this._config.maxAgeSeconds) {
269
284
  return true;
270
285
  }
271
286
  const dateHeaderTimestamp = this._getDateHeaderTimestamp(cachedResponse);
272
287
  if (dateHeaderTimestamp === null) {
273
288
  return true;
274
289
  }
275
- const now = Date.now();
276
- return dateHeaderTimestamp >= now - this._maxAgeSeconds * 1000;
290
+ return dateHeaderTimestamp >= now - this._config.maxAgeSeconds * 1000;
277
291
  }
278
292
  _getDateHeaderTimestamp(cachedResponse) {
279
293
  if (!cachedResponse.headers.has("date")) {
@@ -287,7 +301,7 @@ class ExpirationPlugin {
287
301
  }
288
302
  return headerTime;
289
303
  }
290
- cacheDidUpdate = async ({ cacheName, request })=>{
304
+ async cacheDidUpdate({ cacheName, request }) {
291
305
  if (process.env.NODE_ENV !== "production") {
292
306
  assert.isType(cacheName, "string", {
293
307
  moduleName: "@serwist/expiration",
@@ -305,7 +319,7 @@ class ExpirationPlugin {
305
319
  const cacheExpiration = this._getCacheExpiration(cacheName);
306
320
  await cacheExpiration.updateTimestamp(request.url);
307
321
  await cacheExpiration.expireEntries();
308
- };
322
+ }
309
323
  async deleteCacheAndMetadata() {
310
324
  for (const [cacheName, cacheExpiration] of this._cacheExpirations){
311
325
  await self.caches.delete(cacheName);
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * @private
5
5
  */
6
- declare class CacheTimestampsModel {
6
+ export declare class CacheTimestampsModel {
7
7
  private readonly _cacheName;
8
8
  private _db;
9
9
  /**
@@ -55,14 +55,6 @@ declare class CacheTimestampsModel {
55
55
  * @private
56
56
  */
57
57
  expireEntries(minTimestamp: number, maxCount?: number): Promise<string[]>;
58
- /**
59
- * Takes a URL and returns an ID that will be unique in the object store.
60
- *
61
- * @param url
62
- * @returns
63
- * @private
64
- */
65
- private _getId;
66
58
  /**
67
59
  * Returns an open connection to the database.
68
60
  *
@@ -70,5 +62,4 @@ declare class CacheTimestampsModel {
70
62
  */
71
63
  private getDb;
72
64
  }
73
- export { CacheTimestampsModel };
74
65
  //# sourceMappingURL=CacheTimestampsModel.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"CacheTimestampsModel.d.ts","sourceRoot":"","sources":["../../src/models/CacheTimestampsModel.ts"],"names":[],"mappings":"AAoCA;;;;GAIG;AACH,cAAM,oBAAoB;IACxB,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,GAAG,CAA4C;IAEvD;;;;;OAKG;gBACS,SAAS,EAAE,MAAM;IAI7B;;;;;;OAMG;IACH,OAAO,CAAC,UAAU;IAgBlB;;;;;;OAMG;IACH,OAAO,CAAC,yBAAyB;IAOjC;;;;;OAKG;IACG,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAoBjE;;;;;;OAMG;IACG,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC;IAM5D;;;;;;;;;OASG;IACG,aAAa,CAAC,YAAY,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IA2C/E;;;;;;OAMG;IACH,OAAO,CAAC,MAAM;IAOd;;;;OAIG;YACW,KAAK;CAQpB;AAED,OAAO,EAAE,oBAAoB,EAAE,CAAC"}
1
+ {"version":3,"file":"CacheTimestampsModel.d.ts","sourceRoot":"","sources":["../../src/models/CacheTimestampsModel.ts"],"names":[],"mappings":"AAmCA;;;;GAIG;AACH,qBAAa,oBAAoB;IAC/B,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,GAAG,CAA4C;IAEvD;;;;;OAKG;gBACS,SAAS,EAAE,MAAM;IAI7B;;;;;;OAMG;IACH,OAAO,CAAC,UAAU;IAYlB;;;;;;OAMG;IACH,OAAO,CAAC,yBAAyB;IAOjC;;;;;OAKG;IACG,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAgBjE;;;;;;OAMG;IACG,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC;IAM5D;;;;;;;;;OASG;IACG,aAAa,CAAC,YAAY,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAyB/E;;;;OAIG;YACW,KAAK;CAQpB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serwist/expiration",
3
- "version": "9.0.0-preview.14",
3
+ "version": "9.0.0-preview.16",
4
4
  "type": "module",
5
5
  "description": "A module that expires cached responses based on age or maximum number of entries.",
6
6
  "files": [
@@ -30,12 +30,12 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "idb": "8.0.0",
33
- "@serwist/core": "9.0.0-preview.14"
33
+ "@serwist/core": "9.0.0-preview.16"
34
34
  },
35
35
  "devDependencies": {
36
36
  "rollup": "4.13.0",
37
- "typescript": "5.5.0-dev.20240312",
38
- "@serwist/constants": "9.0.0-preview.14"
37
+ "typescript": "5.5.0-dev.20240323",
38
+ "@serwist/constants": "9.0.0-preview.16"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "typescript": ">=5.0.0"
@@ -6,7 +6,7 @@
6
6
  https://opensource.org/licenses/MIT.
7
7
  */
8
8
 
9
- import { assert, SerwistError, dontWaitFor, logger } from "@serwist/core/internal";
9
+ import { assert, SerwistError, logger } from "@serwist/core/internal";
10
10
 
11
11
  import { CacheTimestampsModel } from "./models/CacheTimestampsModel.js";
12
12
 
@@ -131,7 +131,7 @@ export class CacheExpiration {
131
131
  this._isRunning = false;
132
132
  if (this._rerunRequested) {
133
133
  this._rerunRequested = false;
134
- dontWaitFor(this.expireEntries());
134
+ void this.expireEntries();
135
135
  }
136
136
  }
137
137
 
@@ -187,6 +187,6 @@ export class CacheExpiration {
187
187
  // Make sure we don't attempt another rerun if we're called in the middle of
188
188
  // a cache expiration.
189
189
  this._rerunRequested = false;
190
- await this._timestampModel.expireEntries(Infinity); // Expires all.
190
+ await this._timestampModel.expireEntries(Number.POSITIVE_INFINITY); // Expires all.
191
191
  }
192
192
  }
@@ -6,9 +6,9 @@
6
6
  https://opensource.org/licenses/MIT.
7
7
  */
8
8
 
9
- import type { SerwistPlugin } from "@serwist/core";
9
+ import type { CacheDidUpdateCallbackParam, CachedResponseWillBeUsedCallbackParam, SerwistPlugin } from "@serwist/core";
10
10
  import { registerQuotaErrorCallback } from "@serwist/core";
11
- import { assert, SerwistError, dontWaitFor, getFriendlyURL, logger, privateCacheNames } from "@serwist/core/internal";
11
+ import { assert, SerwistError, getFriendlyURL, logger, privateCacheNames } from "@serwist/core/internal";
12
12
 
13
13
  import { CacheExpiration } from "./CacheExpiration.js";
14
14
 
@@ -19,9 +19,16 @@ export interface ExpirationPluginOptions {
19
19
  */
20
20
  maxEntries?: number;
21
21
  /**
22
- * The maximum age of an entry before it's treated as stale and removed.
22
+ * The maximum number of seconds before an entry is treated as stale and removed.
23
23
  */
24
24
  maxAgeSeconds?: number;
25
+ /**
26
+ * Determines whether `maxAgeSeconds` should be calculated from when an
27
+ * entry was last fetched or when it was last used.
28
+ *
29
+ * @default "last-fetched"
30
+ */
31
+ maxAgeFrom?: "last-fetched" | "last-used";
25
32
  /**
26
33
  * The [`CacheQueryOptions`](https://developer.mozilla.org/en-US/docs/Web/API/Cache/delete#Parameters)
27
34
  * that will be used when calling `delete()` on the cache.
@@ -56,7 +63,6 @@ export interface ExpirationPluginOptions {
56
63
  */
57
64
  export class ExpirationPlugin implements SerwistPlugin {
58
65
  private readonly _config: ExpirationPluginOptions;
59
- private readonly _maxAgeSeconds?: number;
60
66
  private _cacheExpirations: Map<string, CacheExpiration>;
61
67
 
62
68
  /**
@@ -67,7 +73,7 @@ export class ExpirationPlugin implements SerwistPlugin {
67
73
  if (!(config.maxEntries || config.maxAgeSeconds)) {
68
74
  throw new SerwistError("max-entries-or-age-required", {
69
75
  moduleName: "@serwist/expiration",
70
- className: "Plugin",
76
+ className: "ExpirationPlugin",
71
77
  funcName: "constructor",
72
78
  });
73
79
  }
@@ -75,7 +81,7 @@ export class ExpirationPlugin implements SerwistPlugin {
75
81
  if (config.maxEntries) {
76
82
  assert!.isType(config.maxEntries, "number", {
77
83
  moduleName: "@serwist/expiration",
78
- className: "Plugin",
84
+ className: "ExpirationPlugin",
79
85
  funcName: "constructor",
80
86
  paramName: "config.maxEntries",
81
87
  });
@@ -84,18 +90,30 @@ export class ExpirationPlugin implements SerwistPlugin {
84
90
  if (config.maxAgeSeconds) {
85
91
  assert!.isType(config.maxAgeSeconds, "number", {
86
92
  moduleName: "@serwist/expiration",
87
- className: "Plugin",
93
+ className: "ExpirationPlugin",
88
94
  funcName: "constructor",
89
95
  paramName: "config.maxAgeSeconds",
90
96
  });
91
97
  }
98
+
99
+ if (config.maxAgeFrom) {
100
+ assert!.isType(config.maxAgeFrom, "string", {
101
+ moduleName: "@serwist/expiration",
102
+ className: "ExpirationPlugin",
103
+ funcName: "constructor",
104
+ paramName: "config.maxAgeFrom",
105
+ });
106
+ }
92
107
  }
93
108
 
94
109
  this._config = config;
95
- this._maxAgeSeconds = config.maxAgeSeconds;
96
110
  this._cacheExpirations = new Map();
97
111
 
98
- if (config.purgeOnQuotaError) {
112
+ if (!this._config.maxAgeFrom) {
113
+ this._config.maxAgeFrom = "last-fetched";
114
+ }
115
+
116
+ if (this._config.purgeOnQuotaError) {
99
117
  registerQuotaErrorCallback(() => this.deleteCacheAndMetadata());
100
118
  }
101
119
  }
@@ -130,11 +148,11 @@ export class ExpirationPlugin implements SerwistPlugin {
130
148
  * older than the configured `maxAgeSeconds`.
131
149
  *
132
150
  * @param options
133
- * @returns Either the `cachedResponse`, if it's fresh, or `null` if the `Response`
134
- * is older than `maxAgeSeconds`.
151
+ * @returns `cachedResponse` if it is fresh and `null` if it is stale or
152
+ * not available.
135
153
  * @private
136
154
  */
137
- cachedResponseWillBeUsed: SerwistPlugin["cachedResponseWillBeUsed"] = async ({ event, request, cacheName, cachedResponse }) => {
155
+ cachedResponseWillBeUsed({ event, cacheName, request, cachedResponse }: CachedResponseWillBeUsedCallbackParam) {
138
156
  if (!cachedResponse) {
139
157
  return null;
140
158
  }
@@ -144,28 +162,32 @@ export class ExpirationPlugin implements SerwistPlugin {
144
162
  // Expire entries to ensure that even if the expiration date has
145
163
  // expired, it'll only be used once.
146
164
  const cacheExpiration = this._getCacheExpiration(cacheName);
147
- dontWaitFor(cacheExpiration.expireEntries());
148
165
 
149
- // Update the metadata for the request URL to the current timestamp,
150
- // but don't `await` it as we don't want to block the response.
151
- const updateTimestampDone = cacheExpiration.updateTimestamp(request.url);
152
- if (event) {
153
- try {
154
- event.waitUntil(updateTimestampDone);
155
- } catch (error) {
156
- if (process.env.NODE_ENV !== "production") {
157
- // The event may not be a fetch event; only log the URL if it is.
158
- if ("request" in event) {
159
- logger.warn(
160
- `Unable to ensure service worker stays alive when updating cache entry for '${getFriendlyURL((event as FetchEvent).request.url)}'.`,
161
- );
162
- }
166
+ const isMaxAgeFromLastUsed = this._config.maxAgeFrom === "last-used";
167
+
168
+ const done = (async () => {
169
+ // Update the metadata for the request URL to the current timestamp.
170
+ // Only applies if `maxAgeFrom` is `"last-used"`, since the current
171
+ // lifecycle callback is `cachedResponseWillBeUsed`.
172
+ // This needs to be called before `expireEntries()` so as to avoid
173
+ // this URL being marked as expired.
174
+ if (isMaxAgeFromLastUsed) {
175
+ await cacheExpiration.updateTimestamp(request.url);
176
+ }
177
+ await cacheExpiration.expireEntries();
178
+ })();
179
+ try {
180
+ event.waitUntil(done);
181
+ } catch (error) {
182
+ if (process.env.NODE_ENV !== "production") {
183
+ if (event instanceof FetchEvent) {
184
+ logger.warn(`Unable to ensure service worker stays alive when updating cache entry for '${getFriendlyURL(event.request.url)}'.`);
163
185
  }
164
186
  }
165
187
  }
166
188
 
167
189
  return isFresh ? cachedResponse : null;
168
- };
190
+ }
169
191
 
170
192
  /**
171
193
  * @param cachedResponse
@@ -173,12 +195,17 @@ export class ExpirationPlugin implements SerwistPlugin {
173
195
  * @private
174
196
  */
175
197
  private _isResponseDateFresh(cachedResponse: Response): boolean {
176
- if (!this._maxAgeSeconds) {
177
- // We aren't expiring by age, so return true, it's fresh
198
+ const isMaxAgeFromLastUsed = this._config.maxAgeFrom === "last-used";
199
+ // If `maxAgeFrom` is `"last-used"`, the `Date` header doesn't really
200
+ // matter since it is about when the response was created.
201
+ if (isMaxAgeFromLastUsed) {
178
202
  return true;
179
203
  }
180
-
181
- // Check if the 'date' header will suffice a quick expiration check.
204
+ const now = Date.now();
205
+ if (!this._config.maxAgeSeconds) {
206
+ return true;
207
+ }
208
+ // Check if the `Date` header will suffice a quick expiration check.
182
209
  // See https://github.com/GoogleChromeLabs/sw-toolbox/issues/164 for
183
210
  // discussion.
184
211
  const dateHeaderTimestamp = this._getDateHeaderTimestamp(cachedResponse);
@@ -186,16 +213,13 @@ export class ExpirationPlugin implements SerwistPlugin {
186
213
  // Unable to parse date, so assume it's fresh.
187
214
  return true;
188
215
  }
189
-
190
- // If we have a valid headerTime, then our response is fresh iff the
216
+ // If we have a valid headerTime, then our response is fresh if the
191
217
  // headerTime plus maxAgeSeconds is greater than the current time.
192
- const now = Date.now();
193
- return dateHeaderTimestamp >= now - this._maxAgeSeconds * 1000;
218
+ return dateHeaderTimestamp >= now - this._config.maxAgeSeconds * 1000;
194
219
  }
195
220
 
196
221
  /**
197
- * This method will extract the data header and parse it into a useful
198
- * value.
222
+ * Extracts the `Date` header and parse it into an useful value.
199
223
  *
200
224
  * @param cachedResponse
201
225
  * @returns
@@ -206,11 +230,11 @@ export class ExpirationPlugin implements SerwistPlugin {
206
230
  return null;
207
231
  }
208
232
 
209
- const dateHeader = cachedResponse.headers.get("date");
210
- const parsedDate = new Date(dateHeader!);
233
+ const dateHeader = cachedResponse.headers.get("date")!;
234
+ const parsedDate = new Date(dateHeader);
211
235
  const headerTime = parsedDate.getTime();
212
236
 
213
- // If the Date header was invalid for some reason, parsedDate.getTime()
237
+ // If the `Date` header is invalid for some reason, `parsedDate.getTime()`
214
238
  // will return NaN.
215
239
  if (Number.isNaN(headerTime)) {
216
240
  return null;
@@ -226,7 +250,7 @@ export class ExpirationPlugin implements SerwistPlugin {
226
250
  * @param options
227
251
  * @private
228
252
  */
229
- cacheDidUpdate: SerwistPlugin["cacheDidUpdate"] = async ({ cacheName, request }) => {
253
+ async cacheDidUpdate({ cacheName, request }: CacheDidUpdateCallbackParam) {
230
254
  if (process.env.NODE_ENV !== "production") {
231
255
  assert!.isType(cacheName, "string", {
232
256
  moduleName: "@serwist/expiration",
@@ -245,7 +269,7 @@ export class ExpirationPlugin implements SerwistPlugin {
245
269
  const cacheExpiration = this._getCacheExpiration(cacheName);
246
270
  await cacheExpiration.updateTimestamp(request.url);
247
271
  await cacheExpiration.expireEntries();
248
- };
272
+ }
249
273
 
250
274
  /**
251
275
  * Deletes the underlying `Cache` instance associated with this instance and the metadata
@@ -20,7 +20,6 @@ const normalizeURL = (unNormalizedUrl: string) => {
20
20
  };
21
21
 
22
22
  interface CacheTimestampsModelEntry {
23
- id: string;
24
23
  cacheName: string;
25
24
  url: string;
26
25
  timestamp: number;
@@ -28,7 +27,7 @@ interface CacheTimestampsModelEntry {
28
27
 
29
28
  interface CacheDbSchema extends DBSchema {
30
29
  "cache-entries": {
31
- key: string;
30
+ key: [string, string];
32
31
  value: CacheTimestampsModelEntry;
33
32
  indexes: { cacheName: string; timestamp: number };
34
33
  };
@@ -39,7 +38,7 @@ interface CacheDbSchema extends DBSchema {
39
38
  *
40
39
  * @private
41
40
  */
42
- class CacheTimestampsModel {
41
+ export class CacheTimestampsModel {
43
42
  private readonly _cacheName: string;
44
43
  private _db: IDBPDatabase<CacheDbSchema> | null = null;
45
44
 
@@ -61,12 +60,8 @@ class CacheTimestampsModel {
61
60
  * @private
62
61
  */
63
62
  private _upgradeDb(db: IDBPDatabase<CacheDbSchema>) {
64
- // TODO(philipwalton): EdgeHTML doesn't support arrays as a keyPath, so we
65
- // have to use the `id` keyPath here and create our own values (a
66
- // concatenation of `url + cacheName`) instead of simply using
67
- // `keyPath: ['url', 'cacheName']`, which is supported in other browsers.
68
63
  const objStore = db.createObjectStore(CACHE_OBJECT_STORE, {
69
- keyPath: "id",
64
+ keyPath: ["url", "cacheName"],
70
65
  });
71
66
 
72
67
  // TODO(philipwalton): once we don't have to support EdgeHTML, we can
@@ -99,15 +94,11 @@ class CacheTimestampsModel {
99
94
  async setTimestamp(url: string, timestamp: number): Promise<void> {
100
95
  url = normalizeURL(url);
101
96
 
102
- const entry: CacheTimestampsModelEntry = {
97
+ const entry = {
98
+ cacheName: this._cacheName,
103
99
  url,
104
100
  timestamp,
105
- cacheName: this._cacheName,
106
- // Creating an ID from the URL and cache name won't be necessary once
107
- // Edge switches to Chromium and all browsers we support work with
108
- // array keyPaths.
109
- id: this._getId(url),
110
- };
101
+ } satisfies CacheTimestampsModelEntry;
111
102
  const db = await this.getDb();
112
103
  const tx = db.transaction(CACHE_OBJECT_STORE, "readwrite", {
113
104
  durability: "relaxed",
@@ -125,7 +116,7 @@ class CacheTimestampsModel {
125
116
  */
126
117
  async getTimestamp(url: string): Promise<number | undefined> {
127
118
  const db = await this.getDb();
128
- const entry = await db.get(CACHE_OBJECT_STORE, this._getId(url));
119
+ const entry = await db.get(CACHE_OBJECT_STORE, [this._cacheName, normalizeURL(url)]);
129
120
  return entry?.timestamp;
130
121
  }
131
122
 
@@ -141,8 +132,8 @@ class CacheTimestampsModel {
141
132
  */
142
133
  async expireEntries(minTimestamp: number, maxCount?: number): Promise<string[]> {
143
134
  const db = await this.getDb();
144
- let cursor = await db.transaction(CACHE_OBJECT_STORE).store.index("timestamp").openCursor(null, "prev");
145
- const entriesToDelete: CacheTimestampsModelEntry[] = [];
135
+ let cursor = await db.transaction(CACHE_OBJECT_STORE, "readwrite").store.index("timestamp").openCursor(null, "prev");
136
+ const urlsDeleted: string[] = [];
146
137
  let entriesNotDeletedCount = 0;
147
138
  while (cursor) {
148
139
  const result = cursor.value;
@@ -152,16 +143,8 @@ class CacheTimestampsModel {
152
143
  // Delete an entry if it's older than the max age or
153
144
  // if we already have the max number allowed.
154
145
  if ((minTimestamp && result.timestamp < minTimestamp) || (maxCount && entriesNotDeletedCount >= maxCount)) {
155
- // TODO(philipwalton): we should be able to delete the
156
- // entry right here, but doing so causes an iteration
157
- // bug in Safari stable (fixed in TP). Instead we can
158
- // store the keys of the entries to delete, and then
159
- // delete the separate transactions.
160
- // https://github.com/GoogleChrome/workbox/issues/1978
161
- // cursor.delete();
162
-
163
- // We only need to return the URL, not the whole entry.
164
- entriesToDelete.push(cursor.value);
146
+ cursor.delete();
147
+ urlsDeleted.push(result.url);
165
148
  } else {
166
149
  entriesNotDeletedCount++;
167
150
  }
@@ -169,33 +152,9 @@ class CacheTimestampsModel {
169
152
  cursor = await cursor.continue();
170
153
  }
171
154
 
172
- // TODO(philipwalton): once the Safari bug in the following issue is fixed,
173
- // we should be able to remove this loop and do the entry deletion in the
174
- // cursor loop above:
175
- // https://github.com/GoogleChrome/workbox/issues/1978
176
- const urlsDeleted: string[] = [];
177
- for (const entry of entriesToDelete) {
178
- await db.delete(CACHE_OBJECT_STORE, entry.id);
179
- urlsDeleted.push(entry.url);
180
- }
181
-
182
155
  return urlsDeleted;
183
156
  }
184
157
 
185
- /**
186
- * Takes a URL and returns an ID that will be unique in the object store.
187
- *
188
- * @param url
189
- * @returns
190
- * @private
191
- */
192
- private _getId(url: string): string {
193
- // Creating an ID from the URL and cache name won't be necessary once
194
- // Edge switches to Chromium and all browsers we support work with
195
- // array keyPaths.
196
- return `${this._cacheName}|${normalizeURL(url)}`;
197
- }
198
-
199
158
  /**
200
159
  * Returns an open connection to the database.
201
160
  *
@@ -210,5 +169,3 @@ class CacheTimestampsModel {
210
169
  return this._db;
211
170
  }
212
171
  }
213
-
214
- export { CacheTimestampsModel };