1// Copyright (C) 2021 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15/** 16 * This file deals with caching traces in the browser's Cache storage. The 17 * traces are cached so that the UI can gracefully reload a trace when the tab 18 * containing it is discarded by Chrome (e.g. because the tab was not used for 19 * a long time) or when the user accidentally hits reload. 20 */ 21import {ignoreCacheUnactionableErrors} from './errors'; 22import {TraceArrayBufferSource, TraceSource} from './state'; 23 24const TRACE_CACHE_NAME = 'cached_traces'; 25const TRACE_CACHE_SIZE = 10; 26 27let LAZY_CACHE: Cache|undefined = undefined; 28 29async function getCache(): Promise<Cache|undefined> { 30 if (self.caches === undefined) { 31 // The browser doesn't support cache storage or the page is opened from 32 // a non-secure origin. 33 return undefined; 34 } 35 if (LAZY_CACHE !== undefined) { 36 return LAZY_CACHE; 37 } 38 LAZY_CACHE = await caches.open(TRACE_CACHE_NAME); 39 return LAZY_CACHE; 40} 41 42async function cacheDelete(key: Request): Promise<boolean> { 43 try { 44 const cache = await getCache(); 45 if (cache === undefined) return false; // Cache storage not supported. 46 return cache.delete(key); 47 } catch (e) { 48 return ignoreCacheUnactionableErrors(e, false); 49 } 50} 51 52async function cachePut(key: string, value: Response): Promise<void> { 53 try { 54 const cache = await getCache(); 55 if (cache === undefined) return; // Cache storage not supported. 56 cache.put(key, value); 57 } catch (e) { 58 ignoreCacheUnactionableErrors(e, undefined); 59 } 60} 61 62async function cacheMatch(key: Request|string): Promise<Response|undefined> { 63 try { 64 const cache = await getCache(); 65 if (cache === undefined) return undefined; // Cache storage not supported. 66 return cache.match(key); 67 } catch (e) { 68 return ignoreCacheUnactionableErrors(e, undefined); 69 } 70} 71 72async function cacheKeys(): Promise<readonly Request[]> { 73 try { 74 const cache = await getCache(); 75 if (cache === undefined) return []; // Cache storage not supported. 76 return cache.keys(); 77 } catch (e) { 78 return ignoreCacheUnactionableErrors(e, []); 79 } 80} 81 82export async function cacheTrace( 83 traceSource: TraceSource, traceUuid: string): Promise<boolean> { 84 let trace; 85 let title = ''; 86 let fileName = ''; 87 let url = ''; 88 let contentLength = 0; 89 let localOnly = false; 90 switch (traceSource.type) { 91 case 'ARRAY_BUFFER': 92 trace = traceSource.buffer; 93 title = traceSource.title; 94 fileName = traceSource.fileName || ''; 95 url = traceSource.url || ''; 96 contentLength = traceSource.buffer.byteLength; 97 localOnly = traceSource.localOnly || false; 98 break; 99 case 'FILE': 100 trace = await traceSource.file.arrayBuffer(); 101 title = traceSource.file.name; 102 contentLength = traceSource.file.size; 103 break; 104 default: 105 return false; 106 } 107 108 const headers = new Headers([ 109 ['x-trace-title', title], 110 ['x-trace-url', url], 111 ['x-trace-filename', fileName], 112 ['x-trace-local-only', `${localOnly}`], 113 ['content-type', 'application/octet-stream'], 114 ['content-length', `${contentLength}`], 115 [ 116 'expires', 117 // Expires in a week from now (now = upload time) 118 (new Date((new Date()).getTime() + (1000 * 60 * 60 * 24 * 7))) 119 .toUTCString(), 120 ], 121 ]); 122 await deleteStaleEntries(); 123 await cachePut( 124 `/_${TRACE_CACHE_NAME}/${traceUuid}`, new Response(trace, {headers})); 125 return true; 126} 127 128export async function tryGetTrace(traceUuid: string): 129 Promise<TraceArrayBufferSource|undefined> { 130 await deleteStaleEntries(); 131 const response = await cacheMatch(`/_${TRACE_CACHE_NAME}/${traceUuid}`); 132 133 if (!response) return undefined; 134 return { 135 type: 'ARRAY_BUFFER', 136 buffer: await response.arrayBuffer(), 137 title: response.headers.get('x-trace-title') || '', 138 fileName: response.headers.get('x-trace-filename') || undefined, 139 url: response.headers.get('x-trace-url') || undefined, 140 uuid: traceUuid, 141 localOnly: response.headers.get('x-trace-local-only') === 'true', 142 }; 143} 144 145async function deleteStaleEntries() { 146 // Loop through stored traces and invalidate all but the most recent 147 // TRACE_CACHE_SIZE. 148 const keys = await cacheKeys(); 149 const storedTraces: Array<{key: Request, date: Date}> = []; 150 const now = new Date(); 151 const deletions = []; 152 for (const key of keys) { 153 const existingTrace = await cacheMatch(key); 154 if (existingTrace === undefined) { 155 continue; 156 } 157 const expires = existingTrace.headers.get('expires'); 158 if (expires === undefined || expires === null) { 159 // Missing `expires`, so give up and delete which is better than 160 // keeping it around forever. 161 deletions.push(cacheDelete(key)); 162 continue; 163 } 164 const expiryDate = new Date(expires); 165 if (expiryDate < now) { 166 deletions.push(cacheDelete(key)); 167 } else { 168 storedTraces.push({key, date: expiryDate}); 169 } 170 } 171 172 // Sort the traces descending by time, such that most recent ones are placed 173 // at the beginning. Then, take traces from TRACE_CACHE_SIZE onwards and 174 // delete them from cache. 175 const oldTraces = 176 storedTraces.sort((a, b) => b.date.getTime() - a.date.getTime()) 177 .slice(TRACE_CACHE_SIZE); 178 for (const oldTrace of oldTraces) { 179 deletions.push(cacheDelete(oldTrace.key)); 180 } 181 182 // TODO(hjd): Wrong Promise.all here, should use the one that 183 // ignores failures but need to upgrade TypeScript for that. 184 await Promise.all(deletions); 185} 186