UNPKG

serwist

Version:

A Swiss Army knife for service workers.

1 lines 134 kB
{"version":3,"file":"index.mjs","names":["privateCacheNames","privateCacheNames","privateCacheNames","privateCacheNames","clientsClaim","disableDevLogs"],"sources":["../src/cacheNames.ts","../src/lib/broadcastUpdate/constants.ts","../src/lib/broadcastUpdate/responsesAreSame.ts","../src/lib/broadcastUpdate/BroadcastCacheUpdate.ts","../src/lib/broadcastUpdate/BroadcastUpdatePlugin.ts","../src/lib/cacheableResponse/CacheableResponse.ts","../src/lib/cacheableResponse/CacheableResponsePlugin.ts","../src/lib/expiration/models/CacheTimestampsModel.ts","../src/lib/expiration/CacheExpiration.ts","../src/registerQuotaErrorCallback.ts","../src/lib/expiration/ExpirationPlugin.ts","../src/lib/googleAnalytics/constants.ts","../src/lib/googleAnalytics/initializeGoogleAnalytics.ts","../src/lib/precaching/PrecacheFallbackPlugin.ts","../src/lib/rangeRequests/utils/calculateEffectiveBoundaries.ts","../src/lib/rangeRequests/utils/parseRangeHeader.ts","../src/lib/rangeRequests/createPartialResponse.ts","../src/lib/rangeRequests/RangeRequestsPlugin.ts","../src/lib/strategies/CacheFirst.ts","../src/lib/strategies/CacheOnly.ts","../src/lib/strategies/StaleWhileRevalidate.ts","../src/PrecacheRoute.ts","../src/utils/PrecacheCacheKeyPlugin.ts","../src/utils/parsePrecacheOptions.ts","../src/Serwist.ts"],"sourcesContent":["/*\n Copyright 2019 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport { cacheNames as privateCacheNames } from \"./utils/cacheNames.js\";\n\n/**\n * Get the current cache names and prefix/suffix used by Serwist.\n *\n * `cacheNames.precache` is used for precached assets,\n * `cacheNames.googleAnalytics` is used by `@serwist/google-analytics` to\n * store `analytics.js`, and `cacheNames.runtime` is used for everything else.\n *\n * `cacheNames.prefix` can be used to retrieve just the current prefix value.\n * `cacheNames.suffix` can be used to retrieve just the current suffix value.\n *\n * @returns An object with `precache`, `runtime`, `prefix`, and `googleAnalytics` properties.\n */\nexport const cacheNames = {\n get googleAnalytics(): string {\n return privateCacheNames.getGoogleAnalyticsName();\n },\n get precache(): string {\n return privateCacheNames.getPrecacheName();\n },\n get prefix(): string {\n return privateCacheNames.getPrefix();\n },\n get runtime(): string {\n return privateCacheNames.getRuntimeName();\n },\n get suffix(): string {\n return privateCacheNames.getSuffix();\n },\n};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nexport const BROADCAST_UPDATE_MESSAGE_TYPE = \"CACHE_UPDATED\";\nexport const BROADCAST_UPDATE_MESSAGE_META = \"serwist-broadcast-update\";\nexport const BROADCAST_UPDATE_DEFAULT_NOTIFY = true;\nexport const BROADCAST_UPDATE_DEFAULT_HEADERS = [\"content-length\", \"etag\", \"last-modified\"];\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport { logger } from \"../../utils/logger.js\";\nimport { SerwistError } from \"../../utils/SerwistError.js\";\n\n/**\n * Given two responses, compares several header values to see if they are\n * the same or not.\n *\n * @param firstResponse The first response.\n * @param secondResponse The second response.\n * @param headersToCheck A list of headers to check.\n * @returns\n */\nexport const responsesAreSame = (firstResponse: Response, secondResponse: Response, headersToCheck: string[]): boolean => {\n if (process.env.NODE_ENV !== \"production\") {\n if (!(firstResponse instanceof Response && secondResponse instanceof Response)) {\n throw new SerwistError(\"invalid-responses-are-same-args\");\n }\n }\n\n const atLeastOneHeaderAvailable = headersToCheck.some((header) => {\n return firstResponse.headers.has(header) && secondResponse.headers.has(header);\n });\n\n if (!atLeastOneHeaderAvailable) {\n if (process.env.NODE_ENV !== \"production\") {\n logger.warn(\"Unable to determine where the response has been updated because none of the headers that would be checked are present.\");\n logger.debug(\"Attempting to compare the following: \", firstResponse, secondResponse, headersToCheck);\n }\n\n // Just return true, indicating the that responses are the same, since we\n // can't determine otherwise.\n return true;\n }\n\n return headersToCheck.every((header) => {\n const headerStateComparison = firstResponse.headers.has(header) === secondResponse.headers.has(header);\n const headerValueComparison = firstResponse.headers.get(header) === secondResponse.headers.get(header);\n\n return headerStateComparison && headerValueComparison;\n });\n};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport type { CacheDidUpdateCallbackParam } from \"../../types.js\";\nimport { assert } from \"../../utils/assert.js\";\nimport { logger } from \"../../utils/logger.js\";\nimport { resultingClientExists } from \"../../utils/resultingClientExists.js\";\nimport { timeout } from \"../../utils/timeout.js\";\nimport {\n BROADCAST_UPDATE_DEFAULT_HEADERS,\n BROADCAST_UPDATE_DEFAULT_NOTIFY,\n BROADCAST_UPDATE_MESSAGE_META,\n BROADCAST_UPDATE_MESSAGE_TYPE,\n} from \"./constants.js\";\nimport { responsesAreSame } from \"./responsesAreSame.js\";\nimport type { BroadcastCacheUpdateOptions, BroadcastMessage, BroadcastPayload, BroadcastPayloadGenerator } from \"./types.js\";\n\n// UA-sniff Safari: https://stackoverflow.com/questions/7944460/detect-safari-browser\n// TODO(philipwalton): remove once this Safari bug fix has been released.\n// https://bugs.webkit.org/show_bug.cgi?id=201169\nconst isSafari = typeof navigator !== \"undefined\" && /^((?!chrome|android).)*safari/i.test(navigator.userAgent);\n\n// Give TypeScript the correct global.\ndeclare const self: ServiceWorkerGlobalScope;\n/**\n * Generates the default payload used in update messages. By default the\n * payload includes the `cacheName` and `updatedURL` fields.\n *\n * @returns\n * @private\n */\nconst defaultPayloadGenerator = (data: CacheDidUpdateCallbackParam): BroadcastPayload => {\n return {\n cacheName: data.cacheName,\n updatedURL: data.request.url,\n };\n};\n\n/**\n * A class that uses the `postMessage()` API to inform any open windows/tabs when\n * a cached response has been updated.\n *\n * For efficiency's sake, the underlying response bodies are not compared;\n * only specific response headers are checked.\n */\nexport class BroadcastCacheUpdate {\n private readonly _headersToCheck: string[];\n private readonly _generatePayload: BroadcastPayloadGenerator;\n private readonly _notifyAllClients: boolean;\n\n /**\n * Construct an instance of `BroadcastCacheUpdate`.\n *\n * @param options\n */\n constructor({ generatePayload, headersToCheck, notifyAllClients }: BroadcastCacheUpdateOptions = {}) {\n this._headersToCheck = headersToCheck || BROADCAST_UPDATE_DEFAULT_HEADERS;\n this._generatePayload = generatePayload || defaultPayloadGenerator;\n this._notifyAllClients = notifyAllClients ?? BROADCAST_UPDATE_DEFAULT_NOTIFY;\n }\n\n /**\n * Compares two responses and sends a message (via `postMessage()`) to all window clients if the\n * responses differ. Neither of the Responses can be opaque.\n *\n * The message that's posted has the following format (where `payload` can\n * be customized via the `generatePayload` option the instance is created\n * with):\n *\n * ```\n * {\n * type: 'CACHE_UPDATED',\n * meta: 'workbox-broadcast-update',\n * payload: {\n * cacheName: 'the-cache-name',\n * updatedURL: 'https://example.com/'\n * }\n * }\n * ```\n *\n * @param options\n * @returns Resolves once the update is sent.\n */\n async notifyIfUpdated(options: CacheDidUpdateCallbackParam): Promise<void> {\n if (process.env.NODE_ENV !== \"production\") {\n assert!.isType(options.cacheName, \"string\", {\n moduleName: \"serwist\",\n className: \"BroadcastCacheUpdate\",\n funcName: \"notifyIfUpdated\",\n paramName: \"cacheName\",\n });\n assert!.isInstance(options.newResponse, Response, {\n moduleName: \"serwist\",\n className: \"BroadcastCacheUpdate\",\n funcName: \"notifyIfUpdated\",\n paramName: \"newResponse\",\n });\n assert!.isInstance(options.request, Request, {\n moduleName: \"serwist\",\n className: \"BroadcastCacheUpdate\",\n funcName: \"notifyIfUpdated\",\n paramName: \"request\",\n });\n }\n\n // Without two responses there is nothing to compare.\n if (!options.oldResponse) {\n return;\n }\n\n if (!responsesAreSame(options.oldResponse, options.newResponse, this._headersToCheck)) {\n if (process.env.NODE_ENV !== \"production\") {\n logger.log(\"Newer response found (and cached) for:\", options.request.url);\n }\n\n const messageData = {\n type: BROADCAST_UPDATE_MESSAGE_TYPE,\n meta: BROADCAST_UPDATE_MESSAGE_META,\n payload: this._generatePayload(options),\n } satisfies BroadcastMessage;\n\n // For navigation requests, wait until the new window client exists\n // before sending the message\n if (options.request.mode === \"navigate\") {\n let resultingClientId: string | undefined;\n if (options.event instanceof FetchEvent) {\n resultingClientId = options.event.resultingClientId;\n }\n\n const resultingWin = await resultingClientExists(resultingClientId);\n\n // Safari does not currently implement postMessage buffering and\n // there's no good way to feature detect that, so to increase the\n // chances of the message being delivered in Safari, we add a timeout.\n // We also do this if `resultingClientExists()` didn't return a client,\n // which means it timed out, so it's worth waiting a bit longer.\n if (!resultingWin || isSafari) {\n // 3500 is chosen because (according to CrUX data) 80% of mobile\n // websites hit the DOMContentLoaded event in less than 3.5 seconds.\n // And presumably sites implementing service worker are on the\n // higher end of the performance spectrum.\n await timeout(3500);\n }\n }\n\n if (this._notifyAllClients) {\n const windows = await self.clients.matchAll({ type: \"window\" });\n for (const win of windows) {\n win.postMessage(messageData);\n }\n } else {\n // See https://github.com/GoogleChrome/workbox/issues/2895\n if (options.event instanceof FetchEvent) {\n const client = await self.clients.get(options.event.clientId);\n client?.postMessage(messageData);\n }\n }\n }\n }\n}\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport type { CacheDidUpdateCallbackParam, SerwistPlugin } from \"../../types.js\";\nimport { BroadcastCacheUpdate } from \"./BroadcastCacheUpdate.js\";\nimport type { BroadcastCacheUpdateOptions } from \"./types.js\";\n\n/**\n * A class implementing the `cacheDidUpdate` lifecycle callback. It will automatically\n * broadcast a message whenever a cached response is updated.\n */\nexport class BroadcastUpdatePlugin implements SerwistPlugin {\n private readonly _broadcastUpdate: BroadcastCacheUpdate;\n\n /**\n * Construct a {@linkcode BroadcastCacheUpdate} instance with\n * the passed options and calls its {@linkcode BroadcastCacheUpdate.notifyIfUpdated}\n * method whenever the plugin's {@linkcode BroadcastUpdatePlugin.cacheDidUpdate} callback\n * is invoked.\n *\n * @param options\n */\n constructor(options?: BroadcastCacheUpdateOptions) {\n this._broadcastUpdate = new BroadcastCacheUpdate(options);\n }\n\n /**\n * @private\n * @param options The input object to this function.\n */\n cacheDidUpdate(options: CacheDidUpdateCallbackParam) {\n void this._broadcastUpdate.notifyIfUpdated(options);\n }\n}\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport { assert } from \"../../utils/assert.js\";\nimport { getFriendlyURL } from \"../../utils/getFriendlyURL.js\";\nimport { logger } from \"../../utils/logger.js\";\nimport { SerwistError } from \"../../utils/SerwistError.js\";\n\nexport interface CacheableResponseOptions {\n /**\n * One or more HTTP status codes that a response can have to be considered cacheable.\n */\n statuses?: number[];\n /**\n * A mapping of header names and expected values that a response can have and be\n * considered cacheable. If multiple headers are provided, only one needs to be present.\n */\n headers?: HeadersInit;\n}\n\n/**\n * Allows you to set up rules determining what status codes and/or headers need\n * to be present in order for a [response](https://developer.mozilla.org/en-US/docs/Web/API/Response)\n * to be considered cacheable.\n */\nexport class CacheableResponse {\n private readonly _statuses?: CacheableResponseOptions[\"statuses\"];\n private readonly _headers?: Headers;\n\n /**\n * To construct a new `CacheableResponse` instance you must provide at least\n * one of the `config` properties.\n *\n * If both `statuses` and `headers` are specified, then both conditions must\n * be met for the response to be considered cacheable.\n *\n * @param config\n */\n constructor(config: CacheableResponseOptions = {}) {\n if (process.env.NODE_ENV !== \"production\") {\n if (!(config.statuses || config.headers)) {\n throw new SerwistError(\"statuses-or-headers-required\", {\n moduleName: \"serwist\",\n className: \"CacheableResponse\",\n funcName: \"constructor\",\n });\n }\n\n if (config.statuses) {\n assert!.isArray(config.statuses, {\n moduleName: \"serwist\",\n className: \"CacheableResponse\",\n funcName: \"constructor\",\n paramName: \"config.statuses\",\n });\n }\n\n if (config.headers) {\n assert!.isType(config.headers, \"object\", {\n moduleName: \"serwist\",\n className: \"CacheableResponse\",\n funcName: \"constructor\",\n paramName: \"config.headers\",\n });\n }\n }\n\n this._statuses = config.statuses;\n if (config.headers) {\n this._headers = new Headers(config.headers);\n }\n }\n\n /**\n * Checks a response to see whether it's cacheable or not.\n *\n * @param response The response whose cacheability is being\n * checked.\n * @returns `true` if the response is cacheable, and `false`\n * otherwise.\n */\n isResponseCacheable(response: Response): boolean {\n if (process.env.NODE_ENV !== \"production\") {\n assert!.isInstance(response, Response, {\n moduleName: \"serwist\",\n className: \"CacheableResponse\",\n funcName: \"isResponseCacheable\",\n paramName: \"response\",\n });\n }\n\n let cacheable = true;\n\n if (this._statuses) {\n cacheable = this._statuses.includes(response.status);\n }\n\n if (this._headers && cacheable) {\n for (const [headerName, headerValue] of this._headers.entries()) {\n if (response.headers.get(headerName) !== headerValue) {\n cacheable = false;\n break;\n }\n }\n }\n\n if (process.env.NODE_ENV !== \"production\") {\n if (!cacheable) {\n logger.groupCollapsed(\n `The request for '${getFriendlyURL(response.url)}' returned a response that does not meet the criteria for being cached.`,\n );\n\n logger.groupCollapsed(\"View cacheability criteria here.\");\n logger.log(`Cacheable statuses: ${JSON.stringify(this._statuses)}`);\n logger.log(`Cacheable headers: ${JSON.stringify(this._headers, null, 2)}`);\n logger.groupEnd();\n\n const logFriendlyHeaders: { [key: string]: string } = {};\n response.headers.forEach((value, key) => {\n logFriendlyHeaders[key] = value;\n });\n\n logger.groupCollapsed(\"View response status and headers here.\");\n logger.log(`Response status: ${response.status}`);\n logger.log(`Response headers: ${JSON.stringify(logFriendlyHeaders, null, 2)}`);\n logger.groupEnd();\n\n logger.groupCollapsed(\"View full response details here.\");\n logger.log(response.headers);\n logger.log(response);\n logger.groupEnd();\n\n logger.groupEnd();\n }\n }\n\n return cacheable;\n }\n}\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport type { SerwistPlugin } from \"../../types.js\";\nimport type { CacheableResponseOptions } from \"./CacheableResponse.js\";\nimport { CacheableResponse } from \"./CacheableResponse.js\";\n\n/**\n * A class implementing the `cacheWillUpdate` lifecycle callback. This makes it\n * easier to add in cacheability checks to requests made via Serwist's built-in\n * strategies.\n */\nexport class CacheableResponsePlugin implements SerwistPlugin {\n private readonly _cacheableResponse: CacheableResponse;\n\n /**\n * To construct a new `CacheableResponsePlugin` instance you must provide at\n * least one of the `config` properties.\n *\n * If both `statuses` and `headers` are specified, then both conditions must\n * be met for the response to be considered cacheable.\n *\n * @param config\n */\n constructor(config: CacheableResponseOptions) {\n this._cacheableResponse = new CacheableResponse(config);\n }\n\n /**\n * @param options\n * @returns\n * @private\n */\n cacheWillUpdate: SerwistPlugin[\"cacheWillUpdate\"] = async ({ response }) => {\n if (this._cacheableResponse.isResponseCacheable(response)) {\n return response;\n }\n return null;\n };\n}\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport type { DBSchema, IDBPDatabase } from \"idb\";\nimport { deleteDB, openDB } from \"idb\";\n\nconst DB_NAME = \"serwist-expiration\";\nconst CACHE_OBJECT_STORE = \"cache-entries\";\n\nconst normalizeURL = (unNormalizedUrl: string) => {\n const url = new URL(unNormalizedUrl, location.href);\n url.hash = \"\";\n\n return url.href;\n};\n\ninterface CacheTimestampsModelEntry {\n id: string;\n cacheName: string;\n url: string;\n timestamp: number;\n}\n\ninterface CacheDbSchema extends DBSchema {\n \"cache-entries\": {\n key: string;\n value: CacheTimestampsModelEntry;\n indexes: { cacheName: string; timestamp: number };\n };\n}\n\n/**\n * Returns the timestamp model.\n *\n * @private\n */\nexport class CacheTimestampsModel {\n private readonly _cacheName: string;\n private _db: IDBPDatabase<CacheDbSchema> | null = null;\n\n /**\n *\n * @param cacheName\n *\n * @private\n */\n constructor(cacheName: string) {\n this._cacheName = cacheName;\n }\n\n /**\n * Takes a URL and returns an ID that will be unique in the object store.\n *\n * @param url\n * @returns\n * @private\n */\n private _getId(url: string): string {\n return `${this._cacheName}|${normalizeURL(url)}`;\n }\n\n /**\n * Performs an upgrade of indexedDB.\n *\n * @param db\n *\n * @private\n */\n private _upgradeDb(db: IDBPDatabase<CacheDbSchema>) {\n const objStore = db.createObjectStore(CACHE_OBJECT_STORE, {\n keyPath: \"id\",\n });\n\n // TODO(philipwalton): once we don't have to support EdgeHTML, we can\n // create a single index with the keyPath `['cacheName', 'timestamp']`\n // instead of doing both these indexes.\n objStore.createIndex(\"cacheName\", \"cacheName\", { unique: false });\n objStore.createIndex(\"timestamp\", \"timestamp\", { unique: false });\n }\n\n /**\n * Performs an upgrade of indexedDB and deletes deprecated DBs.\n *\n * @param db\n *\n * @private\n */\n private _upgradeDbAndDeleteOldDbs(db: IDBPDatabase<CacheDbSchema>) {\n this._upgradeDb(db);\n if (this._cacheName) {\n void deleteDB(this._cacheName);\n }\n }\n\n /**\n * @param url\n * @param timestamp\n *\n * @private\n */\n async setTimestamp(url: string, timestamp: number): Promise<void> {\n url = normalizeURL(url);\n\n const entry = {\n id: this._getId(url),\n cacheName: this._cacheName,\n url,\n timestamp,\n } satisfies CacheTimestampsModelEntry;\n const db = await this.getDb();\n const tx = db.transaction(CACHE_OBJECT_STORE, \"readwrite\", {\n durability: \"relaxed\",\n });\n await tx.store.put(entry);\n await tx.done;\n }\n\n /**\n * Returns the timestamp stored for a given URL.\n *\n * @param url\n * @returns\n * @private\n */\n async getTimestamp(url: string): Promise<number | undefined> {\n const db = await this.getDb();\n const entry = await db.get(CACHE_OBJECT_STORE, this._getId(url));\n return entry?.timestamp;\n }\n\n /**\n * Iterates through all the entries in the object store (from newest to\n * oldest) and removes entries once either `maxCount` is reached or the\n * entry's timestamp is less than `minTimestamp`.\n *\n * @param minTimestamp\n * @param maxCount\n * @returns\n * @private\n */\n async expireEntries(minTimestamp: number, maxCount?: number): Promise<string[]> {\n const db = await this.getDb();\n let cursor = await db.transaction(CACHE_OBJECT_STORE, \"readwrite\").store.index(\"timestamp\").openCursor(null, \"prev\");\n const urlsDeleted: string[] = [];\n let entriesNotDeletedCount = 0;\n while (cursor) {\n const result = cursor.value;\n // TODO(philipwalton): once we can use a multi-key index, we\n // won't have to check `cacheName` here.\n if (result.cacheName === this._cacheName) {\n // Delete an entry if it's older than the max age or\n // if we already have the max number allowed.\n if ((minTimestamp && result.timestamp < minTimestamp) || (maxCount && entriesNotDeletedCount >= maxCount)) {\n cursor.delete();\n urlsDeleted.push(result.url);\n } else {\n entriesNotDeletedCount++;\n }\n }\n cursor = await cursor.continue();\n }\n\n return urlsDeleted;\n }\n\n /**\n * Returns an open connection to the database.\n *\n * @private\n */\n private async getDb() {\n if (!this._db) {\n this._db = await openDB(DB_NAME, 1, {\n upgrade: this._upgradeDbAndDeleteOldDbs.bind(this),\n });\n }\n return this._db;\n }\n}\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport { assert } from \"../../utils/assert.js\";\nimport { logger } from \"../../utils/logger.js\";\nimport { SerwistError } from \"../../utils/SerwistError.js\";\nimport { CacheTimestampsModel } from \"./models/CacheTimestampsModel.js\";\n\ninterface CacheExpirationConfig {\n /**\n * The maximum number of entries to cache. Entries used least recently will\n * be removed as the maximum is reached.\n */\n maxEntries?: number;\n /**\n * The maximum age of an entry before it's treated as stale and removed.\n */\n maxAgeSeconds?: number;\n /**\n * The [`CacheQueryOptions`](https://developer.mozilla.org/en-US/docs/Web/API/Cache/delete#Parameters)\n * that will be used when calling `delete()` on the cache.\n */\n matchOptions?: CacheQueryOptions;\n}\n\n/**\n * Allows you to expires cached responses based on age or maximum number of entries.\n * @see https://serwist.pages.dev/docs/serwist/core/cache-expiration\n */\nexport class CacheExpiration {\n private _isRunning = false;\n private _rerunRequested = false;\n private readonly _maxEntries?: number;\n private readonly _maxAgeSeconds?: number;\n private readonly _matchOptions?: CacheQueryOptions;\n private readonly _cacheName: string;\n private readonly _timestampModel: CacheTimestampsModel;\n\n /**\n * To construct a new `CacheExpiration` instance you must provide at least\n * one of the `config` properties.\n *\n * @param cacheName Name of the cache to apply restrictions to.\n * @param config\n */\n constructor(cacheName: string, config: CacheExpirationConfig = {}) {\n if (process.env.NODE_ENV !== \"production\") {\n assert!.isType(cacheName, \"string\", {\n moduleName: \"serwist\",\n className: \"CacheExpiration\",\n funcName: \"constructor\",\n paramName: \"cacheName\",\n });\n\n if (!(config.maxEntries || config.maxAgeSeconds)) {\n throw new SerwistError(\"max-entries-or-age-required\", {\n moduleName: \"serwist\",\n className: \"CacheExpiration\",\n funcName: \"constructor\",\n });\n }\n\n if (config.maxEntries) {\n assert!.isType(config.maxEntries, \"number\", {\n moduleName: \"serwist\",\n className: \"CacheExpiration\",\n funcName: \"constructor\",\n paramName: \"config.maxEntries\",\n });\n }\n\n if (config.maxAgeSeconds) {\n assert!.isType(config.maxAgeSeconds, \"number\", {\n moduleName: \"serwist\",\n className: \"CacheExpiration\",\n funcName: \"constructor\",\n paramName: \"config.maxAgeSeconds\",\n });\n }\n }\n\n this._maxEntries = config.maxEntries;\n this._maxAgeSeconds = config.maxAgeSeconds;\n this._matchOptions = config.matchOptions;\n this._cacheName = cacheName;\n this._timestampModel = new CacheTimestampsModel(cacheName);\n }\n\n /**\n * Expires entries for the given cache and given criteria.\n */\n async expireEntries(): Promise<void> {\n if (this._isRunning) {\n this._rerunRequested = true;\n return;\n }\n this._isRunning = true;\n\n const minTimestamp = this._maxAgeSeconds ? Date.now() - this._maxAgeSeconds * 1000 : 0;\n\n const urlsExpired = await this._timestampModel.expireEntries(minTimestamp, this._maxEntries);\n\n // Delete URLs from the cache\n const cache = await self.caches.open(this._cacheName);\n for (const url of urlsExpired) {\n await cache.delete(url, this._matchOptions);\n }\n\n if (process.env.NODE_ENV !== \"production\") {\n if (urlsExpired.length > 0) {\n logger.groupCollapsed(\n `Expired ${urlsExpired.length} ` +\n `${urlsExpired.length === 1 ? \"entry\" : \"entries\"} and removed ` +\n `${urlsExpired.length === 1 ? \"it\" : \"them\"} from the ` +\n `'${this._cacheName}' cache.`,\n );\n logger.log(`Expired the following ${urlsExpired.length === 1 ? \"URL\" : \"URLs\"}:`);\n for (const url of urlsExpired) {\n logger.log(` ${url}`);\n }\n logger.groupEnd();\n } else {\n logger.debug(\"Cache expiration ran and found no entries to remove.\");\n }\n }\n\n this._isRunning = false;\n if (this._rerunRequested) {\n this._rerunRequested = false;\n void this.expireEntries();\n }\n }\n\n /**\n * Updates the timestamp for the given URL, allowing it to be correctly\n * tracked by the class.\n *\n * @param url\n */\n async updateTimestamp(url: string): Promise<void> {\n if (process.env.NODE_ENV !== \"production\") {\n assert!.isType(url, \"string\", {\n moduleName: \"serwist\",\n className: \"CacheExpiration\",\n funcName: \"updateTimestamp\",\n paramName: \"url\",\n });\n }\n\n await this._timestampModel.setTimestamp(url, Date.now());\n }\n\n /**\n * Checks if a URL has expired or not before it's used.\n *\n * This looks the timestamp up in IndexedDB and can be slow.\n *\n * Note: This method does not remove an expired entry, call\n * `expireEntries()` to remove such entries instead.\n *\n * @param url\n * @returns\n */\n async isURLExpired(url: string): Promise<boolean> {\n if (!this._maxAgeSeconds) {\n if (process.env.NODE_ENV !== \"production\") {\n throw new SerwistError(\"expired-test-without-max-age\", {\n methodName: \"isURLExpired\",\n paramName: \"maxAgeSeconds\",\n });\n }\n return false;\n }\n const timestamp = await this._timestampModel.getTimestamp(url);\n const expireOlderThan = Date.now() - this._maxAgeSeconds * 1000;\n return timestamp !== undefined ? timestamp < expireOlderThan : true;\n }\n\n /**\n * Removes the IndexedDB used to keep track of cache expiration metadata.\n */\n async delete(): Promise<void> {\n // Make sure we don't attempt another rerun if we're called in the middle of\n // a cache expiration.\n this._rerunRequested = false;\n await this._timestampModel.expireEntries(Number.POSITIVE_INFINITY); // Expires all.\n }\n}\n","/*\n Copyright 2019 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport { quotaErrorCallbacks } from \"./models/quotaErrorCallbacks.js\";\nimport { assert } from \"./utils/assert.js\";\nimport { logger } from \"./utils/logger.js\";\n\n/**\n * Adds a function to the set of quotaErrorCallbacks that will be executed if\n * there's a quota error.\n *\n * @param callback\n */\n// biome-ignore lint/complexity/noBannedTypes: Can't change Function type\nexport const registerQuotaErrorCallback = (callback: Function): void => {\n if (process.env.NODE_ENV !== \"production\") {\n assert!.isType(callback, \"function\", {\n moduleName: \"@serwist/core\",\n funcName: \"register\",\n paramName: \"callback\",\n });\n }\n\n quotaErrorCallbacks.add(callback);\n\n if (process.env.NODE_ENV !== \"production\") {\n logger.log(\"Registered a callback to respond to quota errors.\", callback);\n }\n};\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport { registerQuotaErrorCallback } from \"../../registerQuotaErrorCallback.js\";\nimport type { CacheDidUpdateCallbackParam, CachedResponseWillBeUsedCallbackParam, SerwistPlugin } from \"../../types.js\";\nimport { assert } from \"../../utils/assert.js\";\nimport { cacheNames as privateCacheNames } from \"../../utils/cacheNames.js\";\nimport { getFriendlyURL } from \"../../utils/getFriendlyURL.js\";\nimport { logger } from \"../../utils/logger.js\";\nimport { SerwistError } from \"../../utils/SerwistError.js\";\nimport type { Strategy } from \"../strategies/Strategy.js\";\nimport { CacheExpiration } from \"./CacheExpiration.js\";\n\nexport interface ExpirationPluginOptions {\n /**\n * The maximum number of entries to cache. Entries used (if `maxAgeFrom` is\n * `\"last-used\"`) or fetched from the network (if `maxAgeFrom` is `\"last-fetched\"`)\n * least recently will be removed as the maximum is reached.\n */\n maxEntries?: number;\n /**\n * The maximum number of seconds before an entry is treated as stale and removed.\n */\n maxAgeSeconds?: number;\n /**\n * Determines whether `maxAgeSeconds` should be calculated from when an\n * entry was last fetched or when it was last used.\n *\n * @default \"last-fetched\"\n */\n maxAgeFrom?: \"last-fetched\" | \"last-used\";\n /**\n * The [`CacheQueryOptions`](https://developer.mozilla.org/en-US/docs/Web/API/Cache/delete#Parameters)\n * that will be used when calling `delete()` on the cache.\n */\n matchOptions?: CacheQueryOptions;\n /**\n * Whether to opt this cache into automatic deletion if the available storage quota has been exceeded.\n */\n purgeOnQuotaError?: boolean;\n}\n\n/**\n * This plugin can be used in a {@linkcode Strategy} to regularly enforce a\n * limit on the age and/or the number of cached requests.\n *\n * It can only be used with {@linkcode Strategy} instances that have a custom `cacheName` property set.\n * In other words, it can't be used to expire entries in strategies that use the default runtime\n * cache name.\n *\n * Whenever a cached response is used or updated, this plugin will look\n * at the associated cache and remove any old or extra responses.\n *\n * When using `maxAgeSeconds`, responses may be used *once* after expiring\n * because the expiration clean up will not have occurred until *after* the\n * cached response has been used. If the response has a \"Date\" header, then a lightweight expiration\n * check is performed, and the response will not be used immediately.\n *\n * When using `maxEntries`, the least recently requested entry will be removed\n * from the cache.\n *\n * @see https://serwist.pages.dev/docs/serwist/runtime-caching/plugins/expiration-plugin\n */\nexport class ExpirationPlugin implements SerwistPlugin {\n private readonly _config: ExpirationPluginOptions;\n private _cacheExpirations: Map<string, CacheExpiration>;\n\n /**\n * @param config\n */\n constructor(config: ExpirationPluginOptions = {}) {\n if (process.env.NODE_ENV !== \"production\") {\n if (!(config.maxEntries || config.maxAgeSeconds)) {\n throw new SerwistError(\"max-entries-or-age-required\", {\n moduleName: \"serwist\",\n className: \"ExpirationPlugin\",\n funcName: \"constructor\",\n });\n }\n\n if (config.maxEntries) {\n assert!.isType(config.maxEntries, \"number\", {\n moduleName: \"serwist\",\n className: \"ExpirationPlugin\",\n funcName: \"constructor\",\n paramName: \"config.maxEntries\",\n });\n }\n\n if (config.maxAgeSeconds) {\n assert!.isType(config.maxAgeSeconds, \"number\", {\n moduleName: \"serwist\",\n className: \"ExpirationPlugin\",\n funcName: \"constructor\",\n paramName: \"config.maxAgeSeconds\",\n });\n }\n\n if (config.maxAgeFrom) {\n assert!.isType(config.maxAgeFrom, \"string\", {\n moduleName: \"serwist\",\n className: \"ExpirationPlugin\",\n funcName: \"constructor\",\n paramName: \"config.maxAgeFrom\",\n });\n }\n }\n\n this._config = config;\n this._cacheExpirations = new Map();\n\n if (!this._config.maxAgeFrom) {\n this._config.maxAgeFrom = \"last-fetched\";\n }\n\n if (this._config.purgeOnQuotaError) {\n registerQuotaErrorCallback(() => this.deleteCacheAndMetadata());\n }\n }\n\n /**\n * A simple helper method to return a CacheExpiration instance for a given\n * cache name.\n *\n * @param cacheName\n * @returns\n * @private\n */\n private _getCacheExpiration(cacheName: string): CacheExpiration {\n if (cacheName === privateCacheNames.getRuntimeName()) {\n throw new SerwistError(\"expire-custom-caches-only\");\n }\n\n let cacheExpiration = this._cacheExpirations.get(cacheName);\n if (!cacheExpiration) {\n cacheExpiration = new CacheExpiration(cacheName, this._config);\n this._cacheExpirations.set(cacheName, cacheExpiration);\n }\n return cacheExpiration;\n }\n\n /**\n * A lifecycle callback that will be triggered automatically when a\n * response is about to be returned from a [`Cache`](https://developer.mozilla.org/en-US/docs/Web/API/Cache).\n * It allows the response to be inspected for freshness and\n * prevents it from being used if the response's `Date` header value is\n * older than the configured `maxAgeSeconds`.\n *\n * @param options\n * @returns `cachedResponse` if it is fresh and `null` if it is stale or\n * not available.\n * @private\n */\n cachedResponseWillBeUsed({ event, cacheName, request, cachedResponse }: CachedResponseWillBeUsedCallbackParam) {\n if (!cachedResponse) {\n return null;\n }\n\n const isFresh = this._isResponseDateFresh(cachedResponse);\n\n // Expire entries to ensure that even if the expiration date has\n // expired, it'll only be used once.\n const cacheExpiration = this._getCacheExpiration(cacheName);\n\n const isMaxAgeFromLastUsed = this._config.maxAgeFrom === \"last-used\";\n\n const done = (async () => {\n // Update the metadata for the request URL to the current timestamp.\n // Only applies if `maxAgeFrom` is `\"last-used\"`, since the current\n // lifecycle callback is `cachedResponseWillBeUsed`.\n // This needs to be called before `expireEntries()` so as to avoid\n // this URL being marked as expired.\n if (isMaxAgeFromLastUsed) {\n await cacheExpiration.updateTimestamp(request.url);\n }\n await cacheExpiration.expireEntries();\n })();\n try {\n event.waitUntil(done);\n } catch {\n if (process.env.NODE_ENV !== \"production\") {\n if (event instanceof FetchEvent) {\n logger.warn(`Unable to ensure service worker stays alive when updating cache entry for '${getFriendlyURL(event.request.url)}'.`);\n }\n }\n }\n\n return isFresh ? cachedResponse : null;\n }\n\n /**\n * @param cachedResponse\n * @returns\n * @private\n */\n private _isResponseDateFresh(cachedResponse: Response): boolean {\n const isMaxAgeFromLastUsed = this._config.maxAgeFrom === \"last-used\";\n // If `maxAgeFrom` is `\"last-used\"`, the `Date` header doesn't really\n // matter since it is about when the response was created.\n if (isMaxAgeFromLastUsed) {\n return true;\n }\n const now = Date.now();\n if (!this._config.maxAgeSeconds) {\n return true;\n }\n // Check if the `Date` header will suffice a quick expiration check.\n // See https://github.com/GoogleChromeLabs/sw-toolbox/issues/164 for\n // discussion.\n const dateHeaderTimestamp = this._getDateHeaderTimestamp(cachedResponse);\n if (dateHeaderTimestamp === null) {\n // Unable to parse date, so assume it's fresh.\n return true;\n }\n // If we have a valid headerTime, then our response is fresh if the\n // headerTime plus maxAgeSeconds is greater than the current time.\n return dateHeaderTimestamp >= now - this._config.maxAgeSeconds * 1000;\n }\n\n /**\n * Extracts the `Date` header and parse it into an useful value.\n *\n * @param cachedResponse\n * @returns\n * @private\n */\n private _getDateHeaderTimestamp(cachedResponse: Response): number | null {\n if (!cachedResponse.headers.has(\"date\")) {\n return null;\n }\n\n const dateHeader = cachedResponse.headers.get(\"date\")!;\n const parsedDate = new Date(dateHeader);\n const headerTime = parsedDate.getTime();\n\n // If the `Date` header is invalid for some reason, `parsedDate.getTime()`\n // will return NaN.\n if (Number.isNaN(headerTime)) {\n return null;\n }\n\n return headerTime;\n }\n\n /**\n * A lifecycle callback that will be triggered automatically when an entry is added\n * to a cache.\n *\n * @param options\n * @private\n */\n async cacheDidUpdate({ cacheName, request }: CacheDidUpdateCallbackParam) {\n if (process.env.NODE_ENV !== \"production\") {\n assert!.isType(cacheName, \"string\", {\n moduleName: \"serwist\",\n className: \"Plugin\",\n funcName: \"cacheDidUpdate\",\n paramName: \"cacheName\",\n });\n assert!.isInstance(request, Request, {\n moduleName: \"serwist\",\n className: \"Plugin\",\n funcName: \"cacheDidUpdate\",\n paramName: \"request\",\n });\n }\n\n const cacheExpiration = this._getCacheExpiration(cacheName);\n await cacheExpiration.updateTimestamp(request.url);\n await cacheExpiration.expireEntries();\n }\n\n /**\n * Deletes the underlying `Cache` instance associated with this instance and the metadata\n * from IndexedDB used to keep track of expiration details for each `Cache` instance.\n *\n * When using cache expiration, calling this method is preferable to calling\n * `caches.delete()` directly, since this will ensure that the IndexedDB\n * metadata is also cleanly removed and that open IndexedDB instances are deleted.\n *\n * Note that if you're *not* using cache expiration for a given cache, calling\n * `caches.delete()` and passing in the cache's name should be sufficient.\n * There is no Serwist-specific method needed for cleanup in that case.\n */\n async deleteCacheAndMetadata(): Promise<void> {\n // Do this one at a time instead of all at once via `Promise.all()` to\n // reduce the chance of inconsistency if a promise rejects.\n for (const [cacheName, cacheExpiration] of this._cacheExpirations) {\n await self.caches.delete(cacheName);\n await cacheExpiration.delete();\n }\n\n // Reset this._cacheExpirations to its initial state.\n this._cacheExpirations = new Map();\n }\n}\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nexport const QUEUE_NAME = \"serwist-google-analytics\";\nexport const MAX_RETENTION_TIME = 60 * 48; // Two days in minutes\nexport const GOOGLE_ANALYTICS_HOST = \"www.google-analytics.com\";\nexport const GTM_HOST = \"www.googletagmanager.com\";\nexport const ANALYTICS_JS_PATH = \"/analytics.js\";\nexport const GTAG_JS_PATH = \"/gtag/js\";\nexport const GTM_JS_PATH = \"/gtm.js\";\nexport const COLLECT_DEFAULT_PATH = \"/collect\";\n\n// This RegExp matches all known Measurement Protocol single-hit collect\n// endpoints. Most of the time the default path (/collect) is used, but\n// occasionally an experimental endpoint is used when testing new features,\n// (e.g. /r/collect or /j/collect)\nexport const COLLECT_PATHS_REGEX = /^\\/(\\w+\\/)?collect/;\n","/*\n Copyright 2018 Google LLC\n\n Use of this source code is governed by an MIT-style\n license that can be found in the LICENSE file or at\n https://opensource.org/licenses/MIT.\n*/\n\nimport { NetworkFirst } from \"../../lib/strategies/NetworkFirst.js\";\nimport { NetworkOnly } from \"../../lib/strategies/NetworkOnly.js\";\nimport { Route } from \"../../Route.js\";\nimport type { Serwist } from \"../../Serwist.js\";\nimport type { RouteMatchCallbackOptions } from \"../../types.js\";\nimport { cacheNames as privateCacheNames } from \"../../utils/cacheNames.js\";\nimport { getFriendlyURL } from \"../../utils/getFriendlyURL.js\";\nimport { logger } from \"../../utils/logger.js\";\nimport { BackgroundSyncPlugin } from \"../backgroundSync/BackgroundSyncPlugin.js\";\nimport type { BackgroundSyncQueue, BackgroundSyncQueueEntry } from \"../backgroundSync/BackgroundSyncQueue.js\";\nimport {\n ANALYTICS_JS_PATH,\n COLLECT_PATHS_REGEX,\n GOOGLE_ANALYTICS_HOST,\n GTAG_JS_PATH,\n GTM_HOST,\n GTM_JS_PATH,\n MAX_RETENTION_TIME,\n QUEUE_NAME,\n} from \"./constants.js\";\n\nexport interface GoogleAnalyticsInitializeOptions {\n serwist: Serwist;\n /**\n * The cache name to store and retrieve analytics.js. Defaults to Serwist's default cache names.\n */\n cacheName?: string;\n /**\n * [Measurement Protocol parameters](https://developers.google.com/analytics/devguides/collection/protocol/v1/parameters),\n * expressed as key/value pairs, to be added to replayed Google Analytics\n * requests. This can be used to, e.g., set a custom dimension indicating\n * that the request was replayed.\n */\n parameterOverrides?: { [paramName: string]: string };\n /**\n * A function that allows you to modify the hit parameters prior to replaying\n * the hit. The function is invoked with the original hit's URLSearchParams\n * object as its only argument.\n */\n hitFilter?: (params: URLSearchParams) => void;\n}\n\n/**\n * Creates the requestWillDequeue callback to be used with the background\n * sync plugin. The callback takes the failed request and adds the\n * `qt` param based on the current time, as well as applies any other\n * user-defined hit modifications.\n *\n * @param config\n * @returns The requestWillDequeue callback function.\n * @private\n */\nconst createOnSyncCallback = (config: Pick<GoogleAnalyticsInitializeOptions, \"parameterOverrides\" | \"hitFilter\">) => {\n return async ({ queue }: { queue: BackgroundSyncQueue }) => {\n let entry: BackgroundSyncQueueEntry | undefined;\n while ((entry = await queue.shiftRequest())) {\n const { request, timestamp } = entry;\n const url = new URL(request.url);\n\n try {\n // Measurement protocol requests can set their payload parameters in\n // either the URL query string (for GET requests) or the POST body.\n const params = request.method === \"POST\" ? new URLSearchParams(await request.clone().text()) : url.searchParams;\n\n // Calculate the qt param, accounting for the fact that an existing\n // qt param may be present and should be updated rather than replaced.\n const originalHitTime = timestamp! - (Number(params.get(\"qt\")) || 0);\n const queueTime = Date.now() - originalHitTime;\n\n // Set the qt param prior to applying hitFilter or parameterOverrides.\n params.set(\"qt\", String(queueTime));\n\n // Apply `parameterOverrides`, if set.\n if (config.parameterOverrides) {\n for (const param of Object.keys(config.parameterOverrides)) {\n const value = config.parameterOverrides[param];\n params.set(param, value);\n }\n }\n\n // Apply `hitFilter`, if set.\n if (typeof config.hitFilter === \"function\") {\n config.hitFilter.call(null, params);\n }\n\n // Retry the fetch. Ignore URL search params from the URL as they're\n // now in the post body.\n await fetch(\n new Request(url.origin + url.pathname, {\n body: params.toString(),\n method: \"POST\",\n mode: \"cors\",\n credentials: \"omit\",\n headers: { \"Content-Type\": \"text/plain\" },\n }),\n );\n\n if (process.env.NODE_ENV !== \"production\") {\n logger.log(`Request for '${getFriendlyURL(url.href)}' has been replayed`);\n }\n } catch (err) {\n await queue.unshiftRequest(entry);\n\n if (process.env.NODE_ENV !== \"production\") {\n logger.log(`Request for '${getFriendlyURL(url.href)}' failed to replay, putting it back in the queue.`);\n }\n throw err;\n }\n }\n if (process.env.NODE_ENV !== \"production\") {\n logger.log(\"All Google Analytics request successfully replayed; \" + \"the queue is now empty!\");\n }\n };\n};\n\n/**\n * Creates GET and POST routes to catch failed Measurement Protocol hits.\n *\n * @param bgSyncPlugin\n * @returns The created routes.\n * @private\n */\nconst createCollectRoutes = (bgSyncPlugin: BackgroundSyncPlugin) => {\n const match = ({ url }: RouteMatchCallbackOptions) => url.hostname === GOOGLE_ANALYTICS_HOST && COLLECT_PATHS_REGEX.test(url.pathname);\n\n const handler = new NetworkOnly({\n plugins: [bgSyncPlugin],\n });\n\n return [new Route(match, handler, \"GET\"), new Route(match, handler, \"POST\")];\n};\n\n/**\n * Creates a route with a network first strategy for the analytics.js script.\n *\n * @param cacheName\n * @returns The created route.\n * @private\n */\nconst createAnalyticsJsRoute = (cacheName: string) => {\n const match = ({ url }: RouteMatchCallbackOptions) => url.hostname === GOOGLE_ANALYTICS_HOST && url.pathname === ANALYTICS_JS_PATH;\n\n const handler = new NetworkFirst({ cacheName });\n\n return new Route(match, handler, \"GET\");\n};\n\n/**\n * Creates a route with a network first strategy for the gtag.js script.\n *\n * @param cacheName\n * @returns The created route.\n * @private\n */\nconst createGtagJsRoute = (cacheName: string) => {\n const match = ({ url }: RouteMatchCallbackOptions) => url.hostname === GTM_HOST && url.pathname === GTAG_JS_PATH;\n\n const handler = new NetworkFirst({ cacheName });\n\n return new Route(match, handler, \"GET\");\n};\n\n/**\n * Creates a route with a network first strategy for the gtm.js script.\n *\n * @param cacheName\n * @returns The created route.\n * @private\n */\nconst createGtmJsRoute = (cacheName: string) => {\n const match = ({ url }: RouteMatchCallbackOptions) => url.hostname === GTM_HOST && url.pathname === GTM_JS_PATH;\n\n const handler = new NetworkFirst({ cacheName });\n\n return new Route(match, handler, \"GET\");\n};\n\n/**\n * Initialize Serwist's offline Google Analytics v3 support.\n *\n * @param options\n */\nexport const initializeGoogleAnalytics = ({ serwist, cacheName, ...options }: GoogleAnalyticsInitializeOptions): void => {\n const resolvedCacheName = privateCacheNames.getGoogleAnalyticsName(cacheName);\n\n const bgSyncPlugin = new BackgroundSyncPlugin(QUEUE_NAME, {\n maxRetentionTi