UNPKG

@angular/service-worker

Version:

Angular - service worker tooling!

1,015 lines (1,007 loc) • 137 kB
(function () { 'use strict'; /** * @license * Copyright Google Inc. All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ /** * Adapts the service worker to its runtime environment. * * Mostly, this is used to mock out identifiers which are otherwise read * from the global scope. */ class Adapter { /** * Wrapper around the `Request` constructor. */ newRequest(input, init) { return new Request(input, init); } /** * Wrapper around the `Response` constructor. */ newResponse(body, init) { return new Response(body, init); } /** * Wrapper around the `Headers` constructor. */ newHeaders(headers) { return new Headers(headers); } /** * Test if a given object is an instance of `Client`. */ isClient(source) { return (source instanceof Client); } /** * Read the current UNIX time in milliseconds. */ get time() { return Date.now(); } /** * Extract the pathname of a URL. */ parseUrl(url, relativeTo) { const parsed = new URL(url, relativeTo); return { origin: parsed.origin, path: parsed.pathname }; } /** * Wait for a given amount of time before completing a Promise. */ timeout(ms) { return new Promise(resolve => { setTimeout(() => resolve(), ms); }); } } /** * @license * Copyright Google Inc. All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ /** * An error returned in rejected promises if the given key is not found in the table. */ class NotFound { constructor(table, key) { this.table = table; this.key = key; } } /** * @license * Copyright Google Inc. All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ /** * An implementation of a `Database` that uses the `CacheStorage` API to serialize * state within mock `Response` objects. */ class CacheDatabase { constructor(scope, adapter) { this.scope = scope; this.adapter = adapter; this.tables = new Map(); } 'delete'(name) { if (this.tables.has(name)) { this.tables.delete(name); } return this.scope.caches.delete(`ngsw:db:${name}`); } list() { return this.scope.caches.keys().then(keys => keys.filter(key => key.startsWith('ngsw:db:'))); } open(name) { if (!this.tables.has(name)) { const table = this.scope.caches.open(`ngsw:db:${name}`) .then(cache => new CacheTable(name, cache, this.adapter)); this.tables.set(name, table); } return this.tables.get(name); } } /** * A `Table` backed by a `Cache`. */ class CacheTable { constructor(table, cache, adapter) { this.table = table; this.cache = cache; this.adapter = adapter; } request(key) { return this.adapter.newRequest('/' + key); } 'delete'(key) { return this.cache.delete(this.request(key)); } keys() { return this.cache.keys().then(requests => requests.map(req => req.url.substr(1))); } read(key) { return this.cache.match(this.request(key)).then(res => { if (res === undefined) { return Promise.reject(new NotFound(this.table, key)); } return res.json(); }); } write(key, value) { return this.cache.put(this.request(key), this.adapter.newResponse(JSON.stringify(value))); } } /** * @license * Copyright Google Inc. All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ var UpdateCacheStatus; (function (UpdateCacheStatus) { UpdateCacheStatus[UpdateCacheStatus["NOT_CACHED"] = 0] = "NOT_CACHED"; UpdateCacheStatus[UpdateCacheStatus["CACHED_BUT_UNUSED"] = 1] = "CACHED_BUT_UNUSED"; UpdateCacheStatus[UpdateCacheStatus["CACHED"] = 2] = "CACHED"; })(UpdateCacheStatus || (UpdateCacheStatus = {})); /** * @license * Copyright Google Inc. All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ class SwCriticalError extends Error { constructor() { super(...arguments); this.isCritical = true; } } function errorToString(error) { if (error instanceof Error) { return `${error.message}\n${error.stack}`; } else { return `${error}`; } } /** * @license * Copyright Google Inc. All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ /** * Compute the SHA1 of the given string * * see http://csrc.nist.gov/publications/fips/fips180-4/fips-180-4.pdf * * WARNING: this function has not been designed not tested with security in mind. * DO NOT USE IT IN A SECURITY SENSITIVE CONTEXT. * * Borrowed from @angular/compiler/src/i18n/digest.ts */ function sha1(str) { const utf8 = str; const words32 = stringToWords32(utf8, Endian.Big); return _sha1(words32, utf8.length * 8); } function sha1Binary(buffer) { const words32 = arrayBufferToWords32(buffer, Endian.Big); return _sha1(words32, buffer.byteLength * 8); } function _sha1(words32, len) { const w = new Array(80); let [a, b, c, d, e] = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0]; words32[len >> 5] |= 0x80 << (24 - len % 32); words32[((len + 64 >> 9) << 4) + 15] = len; for (let i = 0; i < words32.length; i += 16) { const [h0, h1, h2, h3, h4] = [a, b, c, d, e]; for (let j = 0; j < 80; j++) { if (j < 16) { w[j] = words32[i + j]; } else { w[j] = rol32(w[j - 3] ^ w[j - 8] ^ w[j - 14] ^ w[j - 16], 1); } const [f, k] = fk(j, b, c, d); const temp = [rol32(a, 5), f, e, k, w[j]].reduce(add32); [e, d, c, b, a] = [d, c, rol32(b, 30), a, temp]; } [a, b, c, d, e] = [add32(a, h0), add32(b, h1), add32(c, h2), add32(d, h3), add32(e, h4)]; } return byteStringToHexString(words32ToByteString([a, b, c, d, e])); } function add32(a, b) { return add32to64(a, b)[1]; } function add32to64(a, b) { const low = (a & 0xffff) + (b & 0xffff); const high = (a >>> 16) + (b >>> 16) + (low >>> 16); return [high >>> 16, (high << 16) | (low & 0xffff)]; } // Rotate a 32b number left `count` position function rol32(a, count) { return (a << count) | (a >>> (32 - count)); } var Endian; (function (Endian) { Endian[Endian["Little"] = 0] = "Little"; Endian[Endian["Big"] = 1] = "Big"; })(Endian || (Endian = {})); function fk(index, b, c, d) { if (index < 20) { return [(b & c) | (~b & d), 0x5a827999]; } if (index < 40) { return [b ^ c ^ d, 0x6ed9eba1]; } if (index < 60) { return [(b & c) | (b & d) | (c & d), 0x8f1bbcdc]; } return [b ^ c ^ d, 0xca62c1d6]; } function stringToWords32(str, endian) { const words32 = Array((str.length + 3) >>> 2); for (let i = 0; i < words32.length; i++) { words32[i] = wordAt(str, i * 4, endian); } return words32; } function arrayBufferToWords32(buffer, endian) { const words32 = Array((buffer.byteLength + 3) >>> 2); const view = new Uint8Array(buffer); for (let i = 0; i < words32.length; i++) { words32[i] = wordAt(view, i * 4, endian); } return words32; } function byteAt(str, index) { if (typeof str === 'string') { return index >= str.length ? 0 : str.charCodeAt(index) & 0xff; } else { return index >= str.byteLength ? 0 : str[index] & 0xff; } } function wordAt(str, index, endian) { let word = 0; if (endian === Endian.Big) { for (let i = 0; i < 4; i++) { word += byteAt(str, index + i) << (24 - 8 * i); } } else { for (let i = 0; i < 4; i++) { word += byteAt(str, index + i) << 8 * i; } } return word; } function words32ToByteString(words32) { return words32.reduce((str, word) => str + word32ToByteString(word), ''); } function word32ToByteString(word) { let str = ''; for (let i = 0; i < 4; i++) { str += String.fromCharCode((word >>> 8 * (3 - i)) & 0xff); } return str; } function byteStringToHexString(str) { let hex = ''; for (let i = 0; i < str.length; i++) { const b = byteAt(str, i); hex += (b >>> 4).toString(16) + (b & 0x0f).toString(16); } return hex.toLowerCase(); } /** * @license * Copyright Google Inc. All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ var __awaiter = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) { return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; /** * A group of assets that are cached in a `Cache` and managed by a given policy. * * Concrete classes derive from this base and specify the exact caching policy. */ class AssetGroup { constructor(scope, adapter, idle, config, hashes, db, prefix) { this.scope = scope; this.adapter = adapter; this.idle = idle; this.config = config; this.hashes = hashes; this.db = db; this.prefix = prefix; /** * A deduplication cache, to make sure the SW never makes two network requests * for the same resource at once. Managed by `fetchAndCacheOnce`. */ this.inFlightRequests = new Map(); /** * Regular expression patterns. */ this.patterns = []; this.name = config.name; // Patterns in the config are regular expressions disguised as strings. Breathe life into them. this.patterns = this.config.patterns.map(pattern => new RegExp(pattern)); // This is the primary cache, which holds all of the cached requests for this group. If a // resource // isn't in this cache, it hasn't been fetched yet. this.cache = this.scope.caches.open(`${this.prefix}:${this.config.name}:cache`); // This is the metadata table, which holds specific information for each cached URL, such as // the timestamp of when it was added to the cache. this.metadata = this.db.open(`${this.prefix}:${this.config.name}:meta`); // Determine the origin from the registration scope. This is used to differentiate between // relative and absolute URLs. this.origin = this.adapter.parseUrl(this.scope.registration.scope, this.scope.registration.scope).origin; } cacheStatus(url) { return __awaiter(this, void 0, void 0, function* () { const cache = yield this.cache; const meta = yield this.metadata; const res = yield cache.match(this.adapter.newRequest(url)); if (res === undefined) { return UpdateCacheStatus.NOT_CACHED; } try { const data = yield meta.read(url); if (!data.used) { return UpdateCacheStatus.CACHED_BUT_UNUSED; } } catch (_) { // Error on the side of safety and assume cached. } return UpdateCacheStatus.CACHED; }); } /** * Clean up all the cached data for this group. */ cleanup() { return __awaiter(this, void 0, void 0, function* () { yield this.scope.caches.delete(`${this.prefix}:${this.config.name}:cache`); yield this.db.delete(`${this.prefix}:${this.config.name}:meta`); }); } /** * Process a request for a given resource and return it, or return null if it's not available. */ handleFetch(req, ctx) { return __awaiter(this, void 0, void 0, function* () { const url = this.getConfigUrl(req.url); // Either the request matches one of the known resource URLs, one of the patterns for // dynamically matched URLs, or neither. Determine which is the case for this request in // order to decide how to handle it. if (this.config.urls.indexOf(url) !== -1 || this.patterns.some(pattern => pattern.test(url))) { // This URL matches a known resource. Either it's been cached already or it's missing, in // which case it needs to be loaded from the network. // Open the cache to check whether this resource is present. const cache = yield this.cache; // Look for a cached response. If one exists, it can be used to resolve the fetch // operation. const cachedResponse = yield cache.match(req); if (cachedResponse !== undefined) { // A response has already been cached (which presumably matches the hash for this // resource). Check whether it's safe to serve this resource from cache. if (this.hashes.has(url)) { // This resource has a hash, and thus is versioned by the manifest. It's safe to return // the response. return cachedResponse; } else { // This resource has no hash, and yet exists in the cache. Check how old this request is // to make sure it's still usable. if (yield this.needToRevalidate(req, cachedResponse)) { this.idle.schedule(`revalidate(${this.prefix}, ${this.config.name}): ${req.url}`, () => __awaiter(this, void 0, void 0, function* () { yield this.fetchAndCacheOnce(req); })); } // In either case (revalidation or not), the cached response must be good. return cachedResponse; } } // No already-cached response exists, so attempt a fetch/cache operation. The original request // may specify things like credential inclusion, but for assets these are not honored in order // to avoid issues with opaque responses. The SW requests the data itself. const res = yield this.fetchAndCacheOnce(this.adapter.newRequest(req.url)); // If this is successful, the response needs to be cloned as it might be used to respond to // multiple fetch operations at the same time. return res.clone(); } else { return null; } }); } getConfigUrl(url) { // If the URL is relative to the SW's own origin, then only consider the path relative to // the domain root. Determine this by checking the URL's origin against the SW's. const parsed = this.adapter.parseUrl(url, this.scope.registration.scope); if (parsed.origin === this.origin) { // The URL is relative to the SW's origin domain. return parsed.path; } else { return url; } } /** * Some resources are cached without a hash, meaning that their expiration is controlled * by HTTP caching headers. Check whether the given request/response pair is still valid * per the caching headers. */ needToRevalidate(req, res) { return __awaiter(this, void 0, void 0, function* () { // Three different strategies apply here: // 1) The request has a Cache-Control header, and thus expiration needs to be based on its age. // 2) The request has an Expires header, and expiration is based on the current timestamp. // 3) The request has no applicable caching headers, and must be revalidated. if (res.headers.has('Cache-Control')) { // Figure out if there is a max-age directive in the Cache-Control header. const cacheControl = res.headers.get('Cache-Control'); const cacheDirectives = cacheControl // Directives are comma-separated within the Cache-Control header value. .split(',') // Make sure each directive doesn't have extraneous whitespace. .map(v => v.trim()) // Some directives have values (like maxage and s-maxage) .map(v => v.split('=')); // Lowercase all the directive names. cacheDirectives.forEach(v => v[0] = v[0].toLowerCase()); // Find the max-age directive, if one exists. const maxAgeDirective = cacheDirectives.find(v => v[0] === 'max-age'); const cacheAge = maxAgeDirective ? maxAgeDirective[1] : undefined; if (!cacheAge) { // No usable TTL defined. Must assume that the response is stale. return true; } try { const maxAge = 1000 * parseInt(cacheAge); // Determine the origin time of this request. If the SW has metadata on the request (which // it // should), it will have the time the request was added to the cache. If it doesn't for some // reason, the request may have a Date header which will serve the same purpose. let ts; try { // Check the metadata table. If a timestamp is there, use it. const metaTable = yield this.metadata; ts = (yield metaTable.read(req.url)).ts; } catch (e) { // Otherwise, look for a Date header. const date = res.headers.get('Date'); if (date === null) { // Unable to determine when this response was created. Assume that it's stale, and // revalidate it. return true; } ts = Date.parse(date); } const age = this.adapter.time - ts; return age < 0 || age > maxAge; } catch (e) { // Assume stale. return true; } } else if (res.headers.has('Expires')) { // Determine if the expiration time has passed. const expiresStr = res.headers.get('Expires'); try { // The request needs to be revalidated if the current time is later than the expiration // time, if it parses correctly. return this.adapter.time > Date.parse(expiresStr); } catch (e) { // The expiration date failed to parse, so revalidate as a precaution. return true; } } else { // No way to evaluate staleness, so assume the response is already stale. return true; } }); } /** * Fetch the complete state of a cached resource, or return null if it's not found. */ fetchFromCacheOnly(url) { return __awaiter(this, void 0, void 0, function* () { const cache = yield this.cache; const metaTable = yield this.metadata; // Lookup the response in the cache. const response = yield cache.match(this.adapter.newRequest(url)); if (response === undefined) { // It's not found, return null. return null; } // Next, lookup the cached metadata. let metadata = undefined; try { metadata = yield metaTable.read(url); } catch (e) { // Do nothing, not found. This shouldn't happen, but it can be handled. } // Return both the response and any available metadata. return { response, metadata }; }); } /** * Lookup all resources currently stored in the cache which have no associated hash. */ unhashedResources() { return __awaiter(this, void 0, void 0, function* () { const cache = yield this.cache; // Start with the set of all cached URLs. return (yield cache.keys()) .map(request => request.url) // Exclude the URLs which have hashes. .filter(url => !this.hashes.has(url)); }); } /** * Fetch the given resource from the network, and cache it if able. */ fetchAndCacheOnce(req, used = true) { return __awaiter(this, void 0, void 0, function* () { // The `inFlightRequests` map holds information about which caching operations are currently // underway for known resources. If this request appears there, another "thread" is already // in the process of caching it, and this work should not be duplicated. if (this.inFlightRequests.has(req.url)) { // There is a caching operation already in progress for this request. Wait for it to // complete, and hopefully it will have yielded a useful response. return this.inFlightRequests.get(req.url); } // No other caching operation is being attempted for this resource, so it will be owned here. // Go to the network and get the correct version. const fetchOp = this.fetchFromNetwork(req); // Save this operation in `inFlightRequests` so any other "thread" attempting to cache it // will block on this chain instead of duplicating effort. this.inFlightRequests.set(req.url, fetchOp); // Make sure this attempt is cleaned up properly on failure. try { // Wait for a response. If this fails, the request will remain in `inFlightRequests` // indefinitely. const res = yield fetchOp; // It's very important that only successful responses are cached. Unsuccessful responses // should never be cached as this can completely break applications. if (!res.ok) { throw new Error(`Response not Ok (fetchAndCacheOnce): request for ${req.url} returned response ${res.status} ${res.statusText}`); } try { // This response is safe to cache (as long as it's cloned). Wait until the cache operation // is complete. const cache = yield this.scope.caches.open(`${this.prefix}:${this.config.name}:cache`); yield cache.put(req, res.clone()); // If the request is not hashed, update its metadata, especially the timestamp. This is // needed for future determination of whether this cached response is stale or not. if (!this.hashes.has(req.url)) { // Metadata is tracked for requests that are unhashed. const meta = { ts: this.adapter.time, used }; const metaTable = yield this.metadata; yield metaTable.write(req.url, meta); } return res; } catch (err) { // Among other cases, this can happen when the user clears all data through the DevTools, // but the SW is still running and serving another tab. In that case, trying to write to the // caches throws an `Entry was not found` error. // If this happens the SW can no longer work correctly. This situation is unrecoverable. throw new SwCriticalError(`Failed to update the caches for request to '${req.url}' (fetchAndCacheOnce): ${errorToString(err)}`); } } finally { // Finally, it can be removed from `inFlightRequests`. This might result in a double-remove // if some other chain was already making this request too, but that won't hurt anything. this.inFlightRequests.delete(req.url); } }); } fetchFromNetwork(req, redirectLimit = 3) { return __awaiter(this, void 0, void 0, function* () { // Make a cache-busted request for the resource. const res = yield this.cacheBustedFetchFromNetwork(req); // Check for redirected responses, and follow the redirects. if (res['redirected'] && !!res.url) { // If the redirect limit is exhausted, fail with an error. if (redirectLimit === 0) { throw new SwCriticalError(`Response hit redirect limit (fetchFromNetwork): request redirected too many times, next is ${res.url}`); } // Unwrap the redirect directly. return this.fetchFromNetwork(this.adapter.newRequest(res.url), redirectLimit - 1); } return res; }); } /** * Load a particular asset from the network, accounting for hash validation. */ cacheBustedFetchFromNetwork(req) { return __awaiter(this, void 0, void 0, function* () { const url = this.getConfigUrl(req.url); // If a hash is available for this resource, then compare the fetched version with the // canonical hash. Otherwise, the network version will have to be trusted. if (this.hashes.has(url)) { // It turns out this resource does have a hash. Look it up. Unless the fetched version // matches this hash, it's invalid and the whole manifest may need to be thrown out. const canonicalHash = this.hashes.get(url); // Ideally, the resource would be requested with cache-busting to guarantee the SW gets // the freshest version. However, doing this would eliminate any chance of the response // being in the HTTP cache. Given that the browser has recently actively loaded the page, // it's likely that many of the responses the SW needs to cache are in the HTTP cache and // are fresh enough to use. In the future, this could be done by setting cacheMode to // *only* check the browser cache for a cached version of the resource, when cacheMode is // fully supported. For now, the resource is fetched directly, without cache-busting, and // if the hash test fails a cache-busted request is tried before concluding that the // resource isn't correct. This gives the benefit of acceleration via the HTTP cache // without the risk of stale data, at the expense of a duplicate request in the event of // a stale response. // Fetch the resource from the network (possibly hitting the HTTP cache). const networkResult = yield this.safeFetch(req); // Decide whether a cache-busted request is necessary. It might be for two independent // reasons: either the non-cache-busted request failed (hopefully transiently) or if the // hash of the content retrieved does not match the canonical hash from the manifest. It's // only valid to access the content of the first response if the request was successful. let makeCacheBustedRequest = networkResult.ok; if (makeCacheBustedRequest) { // The request was successful. A cache-busted request is only necessary if the hashes // don't match. Compare them, making sure to clone the response so it can be used later // if it proves to be valid. const fetchedHash = sha1Binary(yield networkResult.clone().arrayBuffer()); makeCacheBustedRequest = (fetchedHash !== canonicalHash); } // Make a cache busted request to the network, if necessary. if (makeCacheBustedRequest) { // Hash failure, the version that was retrieved under the default URL did not have the // hash expected. This could be because the HTTP cache got in the way and returned stale // data, or because the version on the server really doesn't match. A cache-busting // request will differentiate these two situations. // TODO: handle case where the URL has parameters already (unlikely for assets). const cacheBustReq = this.adapter.newRequest(this.cacheBust(req.url)); const cacheBustedResult = yield this.safeFetch(cacheBustReq); // If the response was unsuccessful, there's nothing more that can be done. if (!cacheBustedResult.ok) { throw new SwCriticalError(`Response not Ok (cacheBustedFetchFromNetwork): cache busted request for ${req.url} returned response ${cacheBustedResult.status} ${cacheBustedResult.statusText}`); } // Hash the contents. const cacheBustedHash = sha1Binary(yield cacheBustedResult.clone().arrayBuffer()); // If the cache-busted version doesn't match, then the manifest is not an accurate // representation of the server's current set of files, and the SW should give up. if (canonicalHash !== cacheBustedHash) { throw new SwCriticalError(`Hash mismatch (cacheBustedFetchFromNetwork): ${req.url}: expected ${canonicalHash}, got ${cacheBustedHash} (after cache busting)`); } // If it does match, then use the cache-busted result. return cacheBustedResult; } // Excellent, the version from the network matched on the first try, with no need for // cache-busting. Use it. return networkResult; } else { // This URL doesn't exist in our hash database, so it must be requested directly. return this.safeFetch(req); } }); } /** * Possibly update a resource, if it's expired and needs to be updated. A no-op otherwise. */ maybeUpdate(updateFrom, req, cache) { return __awaiter(this, void 0, void 0, function* () { const url = this.getConfigUrl(req.url); const meta = yield this.metadata; // Check if this resource is hashed and already exists in the cache of a prior version. if (this.hashes.has(url)) { const hash = this.hashes.get(url); // Check the caches of prior versions, using the hash to ensure the correct version of // the resource is loaded. const res = yield updateFrom.lookupResourceWithHash(url, hash); // If a previously cached version was available, copy it over to this cache. if (res !== null) { // Copy to this cache. yield cache.put(req, res); yield meta.write(req.url, { ts: this.adapter.time, used: false }); // No need to do anything further with this resource, it's now cached properly. return true; } } // No up-to-date version of this resource could be found. return false; }); } /** * Construct a cache-busting URL for a given URL. */ cacheBust(url) { return url + (url.indexOf('?') === -1 ? '?' : '&') + 'ngsw-cache-bust=' + Math.random(); } safeFetch(req) { return __awaiter(this, void 0, void 0, function* () { try { return yield this.scope.fetch(req); } catch (err) { return this.adapter.newResponse('', { status: 504, statusText: 'Gateway Timeout', }); } }); } } /** * An `AssetGroup` that prefetches all of its resources during initialization. */ class PrefetchAssetGroup extends AssetGroup { initializeFully(updateFrom) { return __awaiter(this, void 0, void 0, function* () { // Open the cache which actually holds requests. const cache = yield this.cache; // Cache all known resources serially. As this reduce proceeds, each Promise waits // on the last before starting the fetch/cache operation for the next request. Any // errors cause fall-through to the final Promise which rejects. yield this.config.urls.reduce((previous, url) => __awaiter(this, void 0, void 0, function* () { // Wait on all previous operations to complete. yield previous; // Construct the Request for this url. const req = this.adapter.newRequest(url); // First, check the cache to see if there is already a copy of this resource. const alreadyCached = (yield cache.match(req)) !== undefined; // If the resource is in the cache already, it can be skipped. if (alreadyCached) { return; } // If an update source is available. if (updateFrom !== undefined && (yield this.maybeUpdate(updateFrom, req, cache))) { return; } // Otherwise, go to the network and hopefully cache the response (if successful). yield this.fetchAndCacheOnce(req, false); }), Promise.resolve()); // Handle updating of unknown (unhashed) resources. This is only possible if there's // a source to update from. if (updateFrom !== undefined) { const metaTable = yield this.metadata; // Select all of the previously cached resources. These are cached unhashed resources // from previous versions of the app, in any asset group. yield (yield updateFrom.previouslyCachedResources()) // First, narrow down the set of resources to those which are handled by this group. // Either it's a known URL, or it matches a given pattern. .filter(url => this.config.urls.some(cacheUrl => cacheUrl === url) || this.patterns.some(pattern => pattern.test(url))) // Finally, process each resource in turn. .reduce((previous, url) => __awaiter(this, void 0, void 0, function* () { yield previous; const req = this.adapter.newRequest(url); // It's possible that the resource in question is already cached. If so, // continue to the next one. const alreadyCached = ((yield cache.match(req)) !== undefined); if (alreadyCached) { return; } // Get the most recent old version of the resource. const res = yield updateFrom.lookupResourceWithoutHash(url); if (res === null || res.metadata === undefined) { // Unexpected, but not harmful. return; } // Write it into the cache. It may already be expired, but it can still serve // traffic until it's updated (stale-while-revalidate approach). yield cache.put(req, res.response); yield metaTable.write(url, Object.assign({}, res.metadata, { used: false })); }), Promise.resolve()); } }); } } class LazyAssetGroup extends AssetGroup { initializeFully(updateFrom) { return __awaiter(this, void 0, void 0, function* () { // No action necessary if no update source is available - resources managed in this group // are all lazily loaded, so there's nothing to initialize. if (updateFrom === undefined) { return; } // Open the cache which actually holds requests. const cache = yield this.cache; // Loop through the listed resources, caching any which are available. yield this.config.urls.reduce((previous, url) => __awaiter(this, void 0, void 0, function* () { // Wait on all previous operations to complete. yield previous; // Construct the Request for this url. const req = this.adapter.newRequest(url); // First, check the cache to see if there is already a copy of this resource. const alreadyCached = (yield cache.match(req)) !== undefined; // If the resource is in the cache already, it can be skipped. if (alreadyCached) { return; } const updated = yield this.maybeUpdate(updateFrom, req, cache); if (this.config.updateMode === 'prefetch' && !updated) { // If the resource was not updated, either it was not cached before or // the previously cached version didn't match the updated hash. In that // case, prefetch update mode dictates that the resource will be updated, // except if it was not previously utilized. Check the status of the // cached resource to see. const cacheStatus = yield updateFrom.recentCacheStatus(url); // If the resource is not cached, or was cached but unused, then it will be // loaded lazily. if (cacheStatus !== UpdateCacheStatus.CACHED) { return; } // Update from the network. yield this.fetchAndCacheOnce(req, false); } }), Promise.resolve()); }); } } /** * @license * Copyright Google Inc. All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ var __awaiter$1 = (undefined && undefined.__awaiter) || function (thisArg, _arguments, P, generator) { return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; /** * Manages an instance of `LruState` and moves URLs to the head of the * chain when requested. */ class LruList { constructor(state) { if (state === undefined) { state = { head: null, tail: null, map: {}, count: 0, }; } this.state = state; } /** * The current count of URLs in the list. */ get size() { return this.state.count; } /** * Remove the tail. */ pop() { // If there is no tail, return null. if (this.state.tail === null) { return null; } const url = this.state.tail; this.remove(url); // This URL has been successfully evicted. return url; } remove(url) { const node = this.state.map[url]; if (node === undefined) { return false; } // Special case if removing the current head. if (this.state.head === url) { // The node is the current head. Special case the removal. if (node.next === null) { // This is the only node. Reset the cache to be empty. this.state.head = null; this.state.tail = null; this.state.map = {}; this.state.count = 0; return true; } // There is at least one other node. Make the next node the new head. const next = this.state.map[node.next]; next.previous = null; this.state.head = next.url; node.next = null; delete this.state.map[url]; this.state.count--; return true; } // The node is not the head, so it has a previous. It may or may not be the tail. // If it is not, then it has a next. First, grab the previous node. const previous = this.state.map[node.previous]; // Fix the forward pointer to skip over node and go directly to node.next. previous.next = node.next; // node.next may or may not be set. If it is, fix the back pointer to skip over node. // If it's not set, then this node happened to be the tail, and the tail needs to be // updated to point to the previous node (removing the tail). if (node.next !== null) { // There is a next node, fix its back pointer to skip this node. this.state.map[node.next].previous = node.previous; } else { // There is no next node - the accessed node must be the tail. Move the tail pointer. this.state.tail = node.previous; } node.next = null; node.previous = null; delete this.state.map[url]; // Count the removal. this.state.count--; return true; } accessed(url) { // When a URL is accessed, its node needs to be moved to the head of the chain. // This is accomplished in two steps: // // 1) remove the node from its position within the chain. // 2) insert the node as the new head. // // Sometimes, a URL is accessed which has not been seen before. In this case, step 1 can // be skipped completely (which will grow the chain by one). Of course, if the node is // already the head, this whole operation can be skipped. if (this.state.head === url) { // The URL is already in the head position, accessing it is a no-op. return; } // Look up the node in the map, and construct a new entry if it's const node = this.state.map[url] || { url, next: null, previous: null }; // Step 1: remove the node from its position within the chain, if it is in the chain. if (this.state.map[url] !== undefined) { this.remove(url); } // Step 2: insert the node at the head of the chain. // First, check if there's an existing head node. If there is, it has previous: null. // Its previous pointer should be set to the node we're inserting. if (this.state.head !== null) { this.state.map[this.state.head].previous = url; } // The next pointer of the node being inserted gets set to the old head, before the head // pointer is updated to this node. node.next = this.state.head; // The new head is the new node. this.state.head = url; // If there is no tail, then this is the first node, and is both the head and the tail. if (this.state.tail === null) { this.state.tail = url; } // Set the node in the map of nodes (if the URL has been seen before, this is a no-op) // and count the insertion. this.state.map[url] = node; this.state.count++; } } /** * A group of cached resources determined by a set of URL patterns which follow a LRU policy * for caching. */ class DataGroup { constructor(scope, adapter, config, db, prefix) { this.scope = scope; this.adapter = adapter; this.config = config; this.db = db; this.prefix = prefix; /** * Tracks the LRU state of resources in this cache. */ this._lru = null; this.patterns = this.config.patterns.map(pattern => new RegExp(pattern)); this.cache = this.scope.caches.open(`${this.prefix}:dynamic:${this.config.name}:cache`); this.lruTable = this.db.open(`${this