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 {TraceArrayBufferSource, TraceSource} from './trace_source'; 22 23const TRACE_CACHE_NAME = 'cached_traces'; 24const TRACE_CACHE_SIZE = 10; 25 26let LAZY_CACHE: Cache | undefined = undefined; 27 28async function getCache(): Promise<Cache | undefined> { 29 if (self.caches === undefined) { 30 // The browser doesn't support cache storage or the page is opened from 31 // a non-secure origin. 32 return undefined; 33 } 34 if (LAZY_CACHE !== undefined) { 35 return LAZY_CACHE; 36 } 37 LAZY_CACHE = await caches.open(TRACE_CACHE_NAME); 38 return LAZY_CACHE; 39} 40 41async function cacheDelete(key: Request): Promise<boolean> { 42 try { 43 const cache = await getCache(); 44 if (cache === undefined) return false; // Cache storage not supported. 45 return await cache.delete(key); 46 } catch (_) { 47 // TODO(288483453): Reinstate: 48 // return ignoreCacheUnactionableErrors(e, false); 49 return false; 50 } 51} 52 53async function cachePut(key: string, value: Response): Promise<void> { 54 try { 55 const cache = await getCache(); 56 if (cache === undefined) return; // Cache storage not supported. 57 await cache.put(key, value); 58 } catch (_) { 59 // TODO(288483453): Reinstate: 60 // ignoreCacheUnactionableErrors(e, undefined); 61 } 62} 63 64async function cacheMatch( 65 key: Request | string, 66): Promise<Response | undefined> { 67 try { 68 const cache = await getCache(); 69 if (cache === undefined) return undefined; // Cache storage not supported. 70 return await cache.match(key); 71 } catch (_) { 72 // TODO(288483453): Reinstate: 73 // ignoreCacheUnactionableErrors(e, undefined); 74 return undefined; 75 } 76} 77 78async function cacheKeys(): Promise<readonly Request[]> { 79 try { 80 const cache = await getCache(); 81 if (cache === undefined) return []; // Cache storage not supported. 82 return await cache.keys(); 83 } catch (e) { 84 // TODO(288483453): Reinstate: 85 // return ignoreCacheUnactionableErrors(e, []); 86 return []; 87 } 88} 89 90export async function cacheTrace( 91 traceSource: TraceSource, 92 traceUuid: string, 93): Promise<boolean> { 94 let trace; 95 let title = ''; 96 let fileName = ''; 97 let url = ''; 98 let contentLength = 0; 99 let localOnly = false; 100 switch (traceSource.type) { 101 case 'ARRAY_BUFFER': 102 trace = traceSource.buffer; 103 title = traceSource.title; 104 fileName = traceSource.fileName ?? ''; 105 url = traceSource.url ?? ''; 106 contentLength = traceSource.buffer.byteLength; 107 localOnly = traceSource.localOnly || false; 108 break; 109 case 'FILE': 110 trace = await traceSource.file.arrayBuffer(); 111 title = traceSource.file.name; 112 contentLength = traceSource.file.size; 113 break; 114 default: 115 return false; 116 } 117 118 const headers = new Headers([ 119 ['x-trace-title', encodeURI(title)], 120 ['x-trace-url', url], 121 ['x-trace-filename', fileName], 122 ['x-trace-local-only', `${localOnly}`], 123 ['content-type', 'application/octet-stream'], 124 ['content-length', `${contentLength}`], 125 [ 126 'expires', 127 // Expires in a week from now (now = upload time) 128 new Date(new Date().getTime() + 1000 * 60 * 60 * 24 * 7).toUTCString(), 129 ], 130 ]); 131 await deleteStaleEntries(); 132 await cachePut( 133 `/_${TRACE_CACHE_NAME}/${traceUuid}`, 134 new Response(trace, {headers}), 135 ); 136 return true; 137} 138 139export async function tryGetTrace( 140 traceUuid: string, 141): Promise<TraceArrayBufferSource | undefined> { 142 await deleteStaleEntries(); 143 const response = await cacheMatch(`/_${TRACE_CACHE_NAME}/${traceUuid}`); 144 145 if (!response) return undefined; 146 return { 147 type: 'ARRAY_BUFFER', 148 buffer: await response.arrayBuffer(), 149 title: decodeURI(response.headers.get('x-trace-title') ?? ''), 150 fileName: response.headers.get('x-trace-filename') ?? undefined, 151 url: response.headers.get('x-trace-url') ?? undefined, 152 uuid: traceUuid, 153 localOnly: response.headers.get('x-trace-local-only') === 'true', 154 }; 155} 156 157async function deleteStaleEntries() { 158 // Loop through stored traces and invalidate all but the most recent 159 // TRACE_CACHE_SIZE. 160 const keys = await cacheKeys(); 161 const storedTraces: Array<{key: Request; date: Date}> = []; 162 const now = new Date(); 163 const deletions = []; 164 for (const key of keys) { 165 const existingTrace = await cacheMatch(key); 166 if (existingTrace === undefined) { 167 continue; 168 } 169 const expires = existingTrace.headers.get('expires'); 170 if (expires === undefined || expires === null) { 171 // Missing `expires`, so give up and delete which is better than 172 // keeping it around forever. 173 deletions.push(cacheDelete(key)); 174 continue; 175 } 176 const expiryDate = new Date(expires); 177 if (expiryDate < now) { 178 deletions.push(cacheDelete(key)); 179 } else { 180 storedTraces.push({key, date: expiryDate}); 181 } 182 } 183 184 // Sort the traces descending by time, such that most recent ones are placed 185 // at the beginning. Then, take traces from TRACE_CACHE_SIZE onwards and 186 // delete them from cache. 187 const oldTraces = storedTraces 188 .sort((a, b) => b.date.getTime() - a.date.getTime()) 189 .slice(TRACE_CACHE_SIZE); 190 for (const oldTrace of oldTraces) { 191 deletions.push(cacheDelete(oldTrace.key)); 192 } 193 194 // TODO(hjd): Wrong Promise.all here, should use the one that 195 // ignores failures but need to upgrade TypeScript for that. 196 await Promise.all(deletions); 197} 198