In some cases, example when the user clears the caches in DevTools but the SW remains active on another tab and keeps references to the deleted caches, trying to write to the cache throws errors (e.g. `Entry was not found`). When this happens, the SW can no longer work correctly and should enter a degraded mode allowing requests to be served from the network. Possibly related: - https://github.com/GoogleChrome/workbox/issues/792 - https://bugs.chromium.org/p/chromium/issues/detail?id=639034 This commits remedies this situation, by ensuring the SW can enter the degraded `EXISTING_CLIENTS_ONLY` mode and forward requests to the network. PR Close #26042
184 lines
5.5 KiB
TypeScript
184 lines
5.5 KiB
TypeScript
/**
|
|
* @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
|
|
*/
|
|
|
|
import {MockRequest, MockResponse} from './fetch';
|
|
|
|
export interface DehydratedResponse {
|
|
body: string|null;
|
|
status: number;
|
|
statusText: string;
|
|
headers: {[name: string]: string};
|
|
}
|
|
|
|
export type DehydratedCache = {
|
|
[url: string]: DehydratedResponse
|
|
};
|
|
export type DehydratedCacheStorage = {
|
|
[name: string]: DehydratedCache
|
|
};
|
|
|
|
export class MockCacheStorage implements CacheStorage {
|
|
private caches = new Map<string, MockCache>();
|
|
|
|
constructor(private origin: string, hydrateFrom?: string) {
|
|
if (hydrateFrom !== undefined) {
|
|
const hydrated = JSON.parse(hydrateFrom) as DehydratedCacheStorage;
|
|
Object.keys(hydrated).forEach(
|
|
name => { this.caches.set(name, new MockCache(this.origin, hydrated[name])); });
|
|
}
|
|
}
|
|
|
|
async has(name: string): Promise<boolean> { return this.caches.has(name); }
|
|
|
|
async keys(): Promise<string[]> { return Array.from(this.caches.keys()); }
|
|
|
|
async open(name: string): Promise<Cache> {
|
|
if (!this.caches.has(name)) {
|
|
this.caches.set(name, new MockCache(this.origin));
|
|
}
|
|
return this.caches.get(name) as any;
|
|
}
|
|
|
|
async match(req: Request): Promise<Response|undefined> {
|
|
return await Array.from(this.caches.values())
|
|
.reduce<Promise<Response|undefined>>(async(answer, cache): Promise<Response|undefined> => {
|
|
const curr = await answer;
|
|
if (curr !== undefined) {
|
|
return curr;
|
|
}
|
|
|
|
return cache.match(req);
|
|
}, Promise.resolve<Response|undefined>(undefined));
|
|
}
|
|
|
|
async 'delete'(name: string): Promise<boolean> {
|
|
if (this.caches.has(name)) {
|
|
this.caches.delete(name);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
dehydrate(): string {
|
|
const dehydrated: DehydratedCacheStorage = {};
|
|
Array.from(this.caches.keys()).forEach(name => {
|
|
const cache = this.caches.get(name) !;
|
|
dehydrated[name] = cache.dehydrate();
|
|
});
|
|
return JSON.stringify(dehydrated);
|
|
}
|
|
}
|
|
|
|
export class MockCache {
|
|
private cache = new Map<string, Response>();
|
|
|
|
constructor(private origin: string, hydrated?: DehydratedCache) {
|
|
if (hydrated !== undefined) {
|
|
Object.keys(hydrated).forEach(url => {
|
|
const resp = hydrated[url];
|
|
this.cache.set(
|
|
url, new MockResponse(
|
|
resp.body,
|
|
{status: resp.status, statusText: resp.statusText, headers: resp.headers}));
|
|
});
|
|
}
|
|
}
|
|
|
|
async add(request: RequestInfo): Promise<void> { throw 'Not implemented'; }
|
|
|
|
async addAll(requests: RequestInfo[]): Promise<void> { throw 'Not implemented'; }
|
|
|
|
async 'delete'(request: RequestInfo): Promise<boolean> {
|
|
const url = (typeof request === 'string' ? request : request.url);
|
|
if (this.cache.has(url)) {
|
|
this.cache.delete(url);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async keys(match?: Request|string): Promise<string[]> {
|
|
if (match !== undefined) {
|
|
throw 'Not implemented';
|
|
}
|
|
return Array.from(this.cache.keys());
|
|
}
|
|
|
|
async match(request: RequestInfo, options?: CacheQueryOptions): Promise<Response> {
|
|
let url = (typeof request === 'string' ? request : request.url);
|
|
if (url.startsWith(this.origin)) {
|
|
url = '/' + url.substr(this.origin.length);
|
|
}
|
|
// TODO: cleanup typings. Typescript doesn't know this can resolve to undefined.
|
|
let res = this.cache.get(url);
|
|
if (res !== undefined) {
|
|
res = res.clone();
|
|
}
|
|
return res !;
|
|
}
|
|
|
|
async matchAll(request?: Request|string, options?: CacheQueryOptions): Promise<Response[]> {
|
|
if (request === undefined) {
|
|
return Array.from(this.cache.values());
|
|
}
|
|
const url = (typeof request === 'string' ? request : request.url);
|
|
if (this.cache.has(url)) {
|
|
return [this.cache.get(url) !];
|
|
} else {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async put(request: RequestInfo, response: Response): Promise<void> {
|
|
const url = (typeof request === 'string' ? request : request.url);
|
|
this.cache.set(url, response.clone());
|
|
|
|
// Even though the body above is cloned, consume it here because the
|
|
// real cache consumes the body.
|
|
await response.text();
|
|
|
|
return;
|
|
}
|
|
|
|
dehydrate(): DehydratedCache {
|
|
const dehydrated: DehydratedCache = {};
|
|
Array.from(this.cache.keys()).forEach(url => {
|
|
const resp = this.cache.get(url) as MockResponse;
|
|
const dehydratedResp = {
|
|
body: resp._body,
|
|
status: resp.status,
|
|
statusText: resp.statusText,
|
|
headers: {},
|
|
} as DehydratedResponse;
|
|
|
|
resp.headers.forEach(
|
|
(value: string, name: string) => { dehydratedResp.headers[name] = value; });
|
|
|
|
dehydrated[url] = dehydratedResp;
|
|
});
|
|
return dehydrated;
|
|
}
|
|
}
|
|
|
|
// This can be used to simulate a situation (bug?), where the user clears the caches from DevTools,
|
|
// while the SW is still running (e.g. serving another tab) and keeps references to the deleted
|
|
// caches.
|
|
export async function clearAllCaches(caches: CacheStorage): Promise<void> {
|
|
const cacheNames = await caches.keys();
|
|
const cacheInstances = await Promise.all(cacheNames.map(name => caches.open(name)));
|
|
|
|
// Delete all cache instances from `CacheStorage`.
|
|
await Promise.all(cacheNames.map(name => caches.delete(name)));
|
|
|
|
// Delete all entries from each cache instance.
|
|
await Promise.all(cacheInstances.map(async cache => {
|
|
const keys = await cache.keys();
|
|
await Promise.all(keys.map(key => cache.delete(key)));
|
|
}));
|
|
}
|