@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.
- package/dist/ExpirationPlugin.d.ts +14 -9
- package/dist/ExpirationPlugin.d.ts.map +1 -1
- package/dist/index.js +56 -42
- package/dist/models/CacheTimestampsModel.d.ts +1 -10
- package/dist/models/CacheTimestampsModel.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/CacheExpiration.ts +3 -3
- package/src/ExpirationPlugin.ts +67 -43
- package/src/models/CacheTimestampsModel.ts +11 -54
|
@@ -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
|
|
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
|
|
70
|
-
*
|
|
75
|
+
* @returns `cachedResponse` if it is fresh and `null` if it is stale or
|
|
76
|
+
* not available.
|
|
71
77
|
* @private
|
|
72
78
|
*/
|
|
73
|
-
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
|
-
*
|
|
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:
|
|
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;
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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 (
|
|
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
|
|
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
|
-
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":"
|
|
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.
|
|
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.
|
|
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.
|
|
38
|
-
"@serwist/constants": "9.0.0-preview.
|
|
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"
|
package/src/CacheExpiration.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
https://opensource.org/licenses/MIT.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { assert, SerwistError,
|
|
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
|
-
|
|
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(
|
|
190
|
+
await this._timestampModel.expireEntries(Number.POSITIVE_INFINITY); // Expires all.
|
|
191
191
|
}
|
|
192
192
|
}
|
package/src/ExpirationPlugin.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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 (
|
|
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
|
|
134
|
-
*
|
|
151
|
+
* @returns `cachedResponse` if it is fresh and `null` if it is stale or
|
|
152
|
+
* not available.
|
|
135
153
|
* @private
|
|
136
154
|
*/
|
|
137
|
-
cachedResponseWillBeUsed
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
return dateHeaderTimestamp >= now - this._maxAgeSeconds * 1000;
|
|
218
|
+
return dateHeaderTimestamp >= now - this._config.maxAgeSeconds * 1000;
|
|
194
219
|
}
|
|
195
220
|
|
|
196
221
|
/**
|
|
197
|
-
*
|
|
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
|
|
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
|
-
|
|
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: "
|
|
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
|
|
97
|
+
const entry = {
|
|
98
|
+
cacheName: this._cacheName,
|
|
103
99
|
url,
|
|
104
100
|
timestamp,
|
|
105
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
156
|
-
|
|
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 };
|