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  }