George Kalpakas 2bd767c4a6 fix(service-worker): do not blow up when caches are unwritable (#26042)
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
2018-09-24 09:53:39 -07:00

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)));
}));
}