| // 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 {TraceArrayBufferSource, TraceSource} from './trace_source'; |
| |
| 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 await cache.delete(key); |
| } catch (_) { |
| // TODO(288483453): Reinstate: |
| // return ignoreCacheUnactionableErrors(e, false); |
| return false; |
| } |
| } |
| |
| async function cachePut(key: string, value: Response): Promise<void> { |
| try { |
| const cache = await getCache(); |
| if (cache === undefined) return; // Cache storage not supported. |
| await cache.put(key, value); |
| } catch (_) { |
| // TODO(288483453): Reinstate: |
| // 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 await cache.match(key); |
| } catch (_) { |
| // TODO(288483453): Reinstate: |
| // ignoreCacheUnactionableErrors(e, undefined); |
| return undefined; |
| } |
| } |
| |
| async function cacheKeys(): Promise<readonly Request[]> { |
| try { |
| const cache = await getCache(); |
| if (cache === undefined) return []; // Cache storage not supported. |
| return await cache.keys(); |
| } catch (e) { |
| // TODO(288483453): Reinstate: |
| // return ignoreCacheUnactionableErrors(e, []); |
| return []; |
| } |
| } |
| |
| 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', encodeURI(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: decodeURI(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); |
| } |