serwist
Version:
A Swiss Army knife for service workers.
194 lines (174 loc) • 6.08 kB
text/typescript
/*
Copyright 2018 Google LLC
Use of this source code is governed by an MIT-style
license that can be found in the LICENSE file or at
https://opensource.org/licenses/MIT.
*/
import { SerwistError } from "../../utils/SerwistError.js";
import { assert } from "../../utils/assert.js";
import { logger } from "../../utils/logger.js";
import { CacheTimestampsModel } from "./models/CacheTimestampsModel.js";
interface CacheExpirationConfig {
/**
* The maximum number of entries to cache. Entries used least recently will
* be removed as the maximum is reached.
*/
maxEntries?: number;
/**
* The maximum age of an entry before it's treated as stale and removed.
*/
maxAgeSeconds?: number;
/**
* The [`CacheQueryOptions`](https://developer.mozilla.org/en-US/docs/Web/API/Cache/delete#Parameters)
* that will be used when calling `delete()` on the cache.
*/
matchOptions?: CacheQueryOptions;
}
/**
* Allows you to expires cached responses based on age or maximum number of entries.
* @see https://serwist.pages.dev/docs/serwist/core/cache-expiration
*/
export class CacheExpiration {
private _isRunning = false;
private _rerunRequested = false;
private readonly _maxEntries?: number;
private readonly _maxAgeSeconds?: number;
private readonly _matchOptions?: CacheQueryOptions;
private readonly _cacheName: string;
private readonly _timestampModel: CacheTimestampsModel;
/**
* To construct a new `CacheExpiration` instance you must provide at least
* one of the `config` properties.
*
* @param cacheName Name of the cache to apply restrictions to.
* @param config
*/
constructor(cacheName: string, config: CacheExpirationConfig = {}) {
if (process.env.NODE_ENV !== "production") {
assert!.isType(cacheName, "string", {
moduleName: "serwist",
className: "CacheExpiration",
funcName: "constructor",
paramName: "cacheName",
});
if (!(config.maxEntries || config.maxAgeSeconds)) {
throw new SerwistError("max-entries-or-age-required", {
moduleName: "serwist",
className: "CacheExpiration",
funcName: "constructor",
});
}
if (config.maxEntries) {
assert!.isType(config.maxEntries, "number", {
moduleName: "serwist",
className: "CacheExpiration",
funcName: "constructor",
paramName: "config.maxEntries",
});
}
if (config.maxAgeSeconds) {
assert!.isType(config.maxAgeSeconds, "number", {
moduleName: "serwist",
className: "CacheExpiration",
funcName: "constructor",
paramName: "config.maxAgeSeconds",
});
}
}
this._maxEntries = config.maxEntries;
this._maxAgeSeconds = config.maxAgeSeconds;
this._matchOptions = config.matchOptions;
this._cacheName = cacheName;
this._timestampModel = new CacheTimestampsModel(cacheName);
}
/**
* Expires entries for the given cache and given criteria.
*/
async expireEntries(): Promise<void> {
if (this._isRunning) {
this._rerunRequested = true;
return;
}
this._isRunning = true;
const minTimestamp = this._maxAgeSeconds ? Date.now() - this._maxAgeSeconds * 1000 : 0;
const urlsExpired = await this._timestampModel.expireEntries(minTimestamp, this._maxEntries);
// Delete URLs from the cache
const cache = await self.caches.open(this._cacheName);
for (const url of urlsExpired) {
await cache.delete(url, this._matchOptions);
}
if (process.env.NODE_ENV !== "production") {
if (urlsExpired.length > 0) {
logger.groupCollapsed(
`Expired ${urlsExpired.length} ` +
`${urlsExpired.length === 1 ? "entry" : "entries"} and removed ` +
`${urlsExpired.length === 1 ? "it" : "them"} from the ` +
`'${this._cacheName}' cache.`,
);
logger.log(`Expired the following ${urlsExpired.length === 1 ? "URL" : "URLs"}:`);
for (const url of urlsExpired) {
logger.log(` ${url}`);
}
logger.groupEnd();
} else {
logger.debug("Cache expiration ran and found no entries to remove.");
}
}
this._isRunning = false;
if (this._rerunRequested) {
this._rerunRequested = false;
void this.expireEntries();
}
}
/**
* Updates the timestamp for the given URL, allowing it to be correctly
* tracked by the class.
*
* @param url
*/
async updateTimestamp(url: string): Promise<void> {
if (process.env.NODE_ENV !== "production") {
assert!.isType(url, "string", {
moduleName: "serwist",
className: "CacheExpiration",
funcName: "updateTimestamp",
paramName: "url",
});
}
await this._timestampModel.setTimestamp(url, Date.now());
}
/**
* Checks if a URL has expired or not before it's used.
*
* This looks the timestamp up in IndexedDB and can be slow.
*
* Note: This method does not remove an expired entry, call
* `expireEntries()` to remove such entries instead.
*
* @param url
* @returns
*/
async isURLExpired(url: string): Promise<boolean> {
if (!this._maxAgeSeconds) {
if (process.env.NODE_ENV !== "production") {
throw new SerwistError("expired-test-without-max-age", {
methodName: "isURLExpired",
paramName: "maxAgeSeconds",
});
}
return false;
}
const timestamp = await this._timestampModel.getTimestamp(url);
const expireOlderThan = Date.now() - this._maxAgeSeconds * 1000;
return timestamp !== undefined ? timestamp < expireOlderThan : true;
}
/**
* Removes the IndexedDB used to keep track of cache expiration metadata.
*/
async delete(): Promise<void> {
// Make sure we don't attempt another rerun if we're called in the middle of
// a cache expiration.
this._rerunRequested = false;
await this._timestampModel.expireEntries(Number.POSITIVE_INFINITY); // Expires all.
}
}