/* 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 { assert } from "../../utils/assert.js"; import { logger } from "../../utils/logger.js"; import { SerwistError } from "../../utils/SerwistError.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 { 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 { 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 { 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 { // 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. } }