go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/common/service_workers/prefetch/prefetch.ts (about) 1 // Copyright 2021 The LUCI Authors. 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 import { 16 getAuthStateCache, 17 getAuthStateCacheSync, 18 queryAuthState, 19 setAuthStateCache, 20 } from '@/common/api/auth_state'; 21 import { 22 BUILD_FIELD_MASK, 23 BuilderID, 24 BuildsService, 25 GetBuildRequest, 26 } from '@/common/services/buildbucket'; 27 import { 28 constructArtifactName, 29 getInvIdFromBuildId, 30 getInvIdFromBuildNum, 31 RESULT_LIMIT, 32 ResultDb, 33 } from '@/common/services/resultdb'; 34 import { cached } from '@/generic_libs/tools/cached_fn'; 35 import { PrpcClientExt } from '@/generic_libs/tools/prpc_client_ext'; 36 import { genCacheKeyForPrpcRequest } from '@/generic_libs/tools/prpc_utils'; 37 import { timeout } from '@/generic_libs/tools/utils'; 38 39 const PRPC_CACHE_KEY_PREFIX = 'prpc-cache-key'; 40 const AUTH_STATE_CACHE_KEY = Math.random().toString(); 41 42 /** 43 * Set the cache duration to 5s. 44 * We don't want to cache the responses for too long because they can be 45 * time-sensitive. 46 * 47 * We could cache some resources (e.g. finalized build, finalized invocation) 48 * longer, but 49 * 1. That requires more involved business logic, should be done at the 50 * application layer rather than at the service worker layer. 51 * 2. The browser should fetch and invalidate the cache momentarily anyway. 52 * 3. If we don't invalidate the cache, we will need to clone the response, 53 * which can be expensive when the response is only used once. 54 */ 55 const CACHE_DURATION = 5000; 56 57 // Cache option that can bypass the service cache but trigger the cachedFetch 58 // cache. 59 const CACHE_OPTION = { acceptCache: false, skipUpdate: true }; 60 61 export class Prefetcher { 62 private readonly authStateUrl = self.origin + '/auth/openid/state'; 63 private readonly cachedUrls: readonly string[]; 64 65 private cachedFetch = cached( 66 // _cacheKey and _expiresIn are not used here but are used in the expire 67 // and key functions below. 68 // they are listed here to help TSC generate the correct type definition. 69 ( 70 info: Parameters<typeof fetch>[0], 71 init: Parameters<typeof fetch>[1], 72 _cacheKey: unknown, 73 _expiresIn: number, 74 ) => this.fetchImpl(info, init), 75 { 76 key: (_info, _init, cacheKey) => cacheKey, 77 expire: ([, , , expiresIn]) => timeout(expiresIn), 78 }, 79 ); 80 81 private readonly prefetchBuildsService: BuildsService; 82 private readonly prefetchResultDBService: ResultDb; 83 84 constructor( 85 private readonly configs: typeof SETTINGS, 86 private readonly fetchImpl: typeof fetch, 87 ) { 88 this.cachedUrls = [ 89 this.authStateUrl, 90 `https://${this.configs.buildbucket.host}/prpc/buildbucket.v2.Builds/GetBuild`, 91 `https://${this.configs.resultdb.host}/prpc/luci.resultdb.v1.ResultDB/GetArtifact`, 92 `https://${this.configs.resultdb.host}/prpc/luci.resultdb.v1.ResultDB/GetInvocation`, 93 `https://${this.configs.resultdb.host}/prpc/luci.resultdb.v1.ResultDB/QueryTestVariants`, 94 ]; 95 this.prefetchBuildsService = new BuildsService( 96 this.makePrpcClient(this.configs.buildbucket.host), 97 ); 98 this.prefetchResultDBService = new ResultDb( 99 this.makePrpcClient(this.configs.resultdb.host), 100 ); 101 } 102 103 private makePrpcClient(host: string) { 104 return new PrpcClientExt( 105 { 106 host, 107 fetchImpl: async (info, init?) => { 108 const req = new Request(info, init); 109 await this.cachedFetch( 110 {}, 111 req, 112 undefined, 113 await genCacheKeyForPrpcRequest(PRPC_CACHE_KEY_PREFIX, req.clone()), 114 CACHE_DURATION, // See the documentation for CACHE_DURATION. 115 ); 116 117 // Abort the function to prevent the response from being consumed. 118 throw new Error(); 119 }, 120 }, 121 122 () => getAuthStateCacheSync()?.accessToken || '', 123 ); 124 } 125 126 /** 127 * Prefetches resources if the pathname matches certain pattern. 128 * Those resources are cached for a short duration and are expected to be 129 * fetched by the browser momentarily. 130 */ 131 async prefetchResources(pathname: string) { 132 // Prefetch services relies on the in-memory cache. 133 // Call getAuthState to populate the in-memory cache. 134 const authState = await getAuthStateCache(); 135 136 const queryAuthStatePromise = queryAuthState((info, init) => 137 this.cachedFetch( 138 {}, 139 info, 140 init, 141 AUTH_STATE_CACHE_KEY, 142 CACHE_DURATION, 143 ).then((res) => res.clone()), 144 ).then(setAuthStateCache); 145 if (!authState) { 146 await queryAuthStatePromise; 147 } 148 149 this.prefetchBuildPageResources(pathname); 150 this.prefetchArtifactPageResources(pathname); 151 } 152 153 /** 154 * Prefetches build page related resources if the URL matches certain pattern. 155 */ 156 private async prefetchBuildPageResources(pathname: string) { 157 let buildId: string | null = null; 158 let buildNum: number | null = null; 159 let builderId: BuilderID | null = null; 160 let invName: string | null = null; 161 162 let match = pathname.match( 163 /^\/ui\/p\/([^/]+)\/builders\/([^/]+)\/([^/]+)\/(b?\d+)\/?/i, 164 ); 165 if (match) { 166 const [project, bucket, builder, buildIdOrNum] = match 167 .slice(1, 5) 168 .map((v) => decodeURIComponent(v)); 169 if (buildIdOrNum.startsWith('b')) { 170 buildId = buildIdOrNum.slice(1); 171 } else { 172 buildNum = Number(buildIdOrNum); 173 builderId = { project, bucket, builder }; 174 } 175 } else { 176 match = pathname.match(/^\/ui\/b\/(\d+)\/?/i); 177 if (match) { 178 buildId = match[1]; 179 } 180 } 181 182 let getBuildRequest: GetBuildRequest | null = null; 183 if (buildId) { 184 getBuildRequest = { id: buildId, fields: BUILD_FIELD_MASK }; 185 invName = 'invocations/' + getInvIdFromBuildId(buildId); 186 } else if (builderId && buildNum) { 187 getBuildRequest = { 188 builder: builderId, 189 buildNumber: buildNum, 190 fields: BUILD_FIELD_MASK, 191 }; 192 invName = 193 'invocations/' + (await getInvIdFromBuildNum(builderId, buildNum)); 194 } 195 196 if (getBuildRequest) { 197 this.prefetchBuildsService 198 .getBuild(getBuildRequest, CACHE_OPTION) 199 .catch((_e) => { 200 // Ignore any error, let the consumer of the cache deal with it. 201 }); 202 } 203 204 if (invName) { 205 this.prefetchResultDBService 206 .getInvocation({ name: invName }, CACHE_OPTION) 207 .catch((_e) => { 208 // Ignore any error, let the consumer of the cache deal with it. 209 }); 210 this.prefetchResultDBService 211 .queryTestVariants( 212 { invocations: [invName], resultLimit: RESULT_LIMIT }, 213 CACHE_OPTION, 214 ) 215 .catch((_e) => { 216 // Ignore any error, let the consumer of the cache deal with it. 217 }); 218 } 219 } 220 221 /** 222 * Prefetches artifact page related resources if the URL matches certain 223 * pattern. 224 */ 225 private async prefetchArtifactPageResources(pathname: string) { 226 const match = pathname.match( 227 /^\/ui\/artifact\/(?:[^/]+)\/invocations\/([^/]+)(?:\/tests\/([^/]+)\/results\/([^/]+))?\/artifacts\/([^/]+)\/?/i, 228 ); 229 if (!match) { 230 return; 231 } 232 const [invocationId, testId, resultId, artifactId] = match 233 .slice(1, 5) 234 .map((v) => (v === undefined ? undefined : decodeURIComponent(v))); 235 236 this.prefetchResultDBService 237 .getArtifact( 238 { 239 name: constructArtifactName({ 240 // Invocation is a compulsory capture group. 241 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 242 invocationId: invocationId!, 243 testId, 244 resultId, 245 // artifactId is a compulsory capture group. 246 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 247 artifactId: artifactId!, 248 }), 249 }, 250 CACHE_OPTION, 251 ) 252 .catch((_e) => { 253 // Ignore any error, let the consumer of the cache deal with it. 254 }); 255 } 256 257 /** 258 * Responds to the event with the cached content if the URL matches certain 259 * pattern. 260 * 261 * Returns true if the URL might be cached. Returns false otherwise. 262 */ 263 respondWithPrefetched(e: FetchEvent) { 264 if (!this.cachedUrls.includes(e.request.url)) { 265 return false; 266 } 267 268 e.respondWith( 269 (async () => { 270 const cacheKey = 271 e.request.url === this.authStateUrl 272 ? AUTH_STATE_CACHE_KEY 273 : await genCacheKeyForPrpcRequest( 274 PRPC_CACHE_KEY_PREFIX, 275 e.request.clone(), 276 ); 277 278 const res = await this.cachedFetch( 279 // The response can't be reused, don't keep it in cache. 280 { skipUpdate: true, invalidateCache: true }, 281 e.request, 282 undefined, 283 cacheKey, 284 0, 285 ); 286 return res; 287 })(), 288 ); 289 290 return true; 291 } 292 }