blob: 11b3aef49b0c649a3f789dd5b541cb186f94091d [file] [log] [blame]
// Copyright (C) 2021 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
/**
* This file deals with caching traces in the browser's Cache storage. The
* traces are cached so that the UI can gracefully reload a trace when the tab
* containing it is discarded by Chrome (e.g. because the tab was not used for
* a long time) or when the user accidentally hits reload.
*/
import {ignoreCacheUnactionableErrors} from './errors';
import {TraceArrayBufferSource, TraceSource} from './state';
const TRACE_CACHE_NAME = 'cached_traces';
const TRACE_CACHE_SIZE = 10;
let LAZY_CACHE: Cache|undefined = undefined;
async function getCache(): Promise<Cache|undefined> {
if (self.caches === undefined) {
// The browser doesn't support cache storage or the page is opened from
// a non-secure origin.
return undefined;
}
if (LAZY_CACHE !== undefined) {
return LAZY_CACHE;
}
LAZY_CACHE = await caches.open(TRACE_CACHE_NAME);
return LAZY_CACHE;
}
async function cacheDelete(key: Request): Promise<boolean> {
try {
const cache = await getCache();
if (cache === undefined) return false; // Cache storage not supported.
return cache.delete(key);
} catch (e) {
return ignoreCacheUnactionableErrors(e, false);
}
}
async function cachePut(key: string, value: Response): Promise<void> {
try {
const cache = await getCache();
if (cache === undefined) return; // Cache storage not supported.
cache.put(key, value);
} catch (e) {
ignoreCacheUnactionableErrors(e, undefined);
}
}
async function cacheMatch(key: Request|string): Promise<Response|undefined> {
try {
const cache = await getCache();
if (cache === undefined) return undefined; // Cache storage not supported.
return cache.match(key);
} catch (e) {
return ignoreCacheUnactionableErrors(e, undefined);
}
}
async function cacheKeys(): Promise<readonly Request[]> {
try {
const cache = await getCache();
if (cache === undefined) return []; // Cache storage not supported.
return cache.keys();
} catch (e) {
return ignoreCacheUnactionableErrors(e, []);
}
}
export async function cacheTrace(
traceSource: TraceSource, traceUuid: string): Promise<boolean> {
let trace;
let title = '';
let fileName = '';
let url = '';
let contentLength = 0;
let localOnly = false;
switch (traceSource.type) {
case 'ARRAY_BUFFER':
trace = traceSource.buffer;
title = traceSource.title;
fileName = traceSource.fileName || '';
url = traceSource.url || '';
contentLength = traceSource.buffer.byteLength;
localOnly = traceSource.localOnly || false;
break;
case 'FILE':
trace = await traceSource.file.arrayBuffer();
title = traceSource.file.name;
contentLength = traceSource.file.size;
break;
default:
return false;
}
const headers = new Headers([
['x-trace-title', title],
['x-trace-url', url],
['x-trace-filename', fileName],
['x-trace-local-only', `${localOnly}`],
['content-type', 'application/octet-stream'],
['content-length', `${contentLength}`],
[
'expires',
// Expires in a week from now (now = upload time)
(new Date((new Date()).getTime() + (1000 * 60 * 60 * 24 * 7)))
.toUTCString(),
],
]);
await deleteStaleEntries();
await cachePut(
`/_${TRACE_CACHE_NAME}/${traceUuid}`, new Response(trace, {headers}));
return true;
}
export async function tryGetTrace(traceUuid: string):
Promise<TraceArrayBufferSource|undefined> {
await deleteStaleEntries();
const response = await cacheMatch(`/_${TRACE_CACHE_NAME}/${traceUuid}`);
if (!response) return undefined;
return {
type: 'ARRAY_BUFFER',
buffer: await response.arrayBuffer(),
title: response.headers.get('x-trace-title') || '',
fileName: response.headers.get('x-trace-filename') || undefined,
url: response.headers.get('x-trace-url') || undefined,
uuid: traceUuid,
localOnly: response.headers.get('x-trace-local-only') === 'true',
};
}
async function deleteStaleEntries() {
// Loop through stored traces and invalidate all but the most recent
// TRACE_CACHE_SIZE.
const keys = await cacheKeys();
const storedTraces: Array<{key: Request, date: Date}> = [];
const now = new Date();
const deletions = [];
for (const key of keys) {
const existingTrace = await cacheMatch(key);
if (existingTrace === undefined) {
continue;
}
const expires = existingTrace.headers.get('expires');
if (expires === undefined || expires === null) {
// Missing `expires`, so give up and delete which is better than
// keeping it around forever.
deletions.push(cacheDelete(key));
continue;
}
const expiryDate = new Date(expires);
if (expiryDate < now) {
deletions.push(cacheDelete(key));
} else {
storedTraces.push({key, date: expiryDate});
}
}
// Sort the traces descending by time, such that most recent ones are placed
// at the beginning. Then, take traces from TRACE_CACHE_SIZE onwards and
// delete them from cache.
const oldTraces =
storedTraces.sort((a, b) => b.date.getTime() - a.date.getTime())
.slice(TRACE_CACHE_SIZE);
for (const oldTrace of oldTraces) {
deletions.push(cacheDelete(oldTrace.key));
}
// TODO(hjd): Wrong Promise.all here, should use the one that
// ignores failures but need to upgrade TypeScript for that.
await Promise.all(deletions);
}