18d307f52SEvan Bacon/**
28d307f52SEvan Bacon * Copyright (c) 2021 Expo, Inc.
38d307f52SEvan Bacon * Copyright (c) 2020 mistval.
48d307f52SEvan Bacon *
58d307f52SEvan Bacon * This source code is licensed under the MIT license found in the
68d307f52SEvan Bacon * LICENSE file in the root directory of this source tree.
78d307f52SEvan Bacon *
88d307f52SEvan Bacon * Based on https://github.com/mistval/node-fetch-cache/blob/9c40ddf786b0de22ce521d8bdaa6347bc44dd629/src/index.js#L1
98d307f52SEvan Bacon * But with TypeScript support to fix Jest tests, and removed unused code.
108d307f52SEvan Bacon */
118d307f52SEvan Baconimport crypto from 'crypto';
128d307f52SEvan Baconimport FormData from 'form-data';
138d307f52SEvan Baconimport fs from 'fs';
148d307f52SEvan Baconimport { Request, RequestInfo, RequestInit, Response } from 'node-fetch';
158d307f52SEvan Baconimport { URLSearchParams } from 'url';
168d307f52SEvan Bacon
178d307f52SEvan Baconimport { FileSystemCache } from './FileSystemCache';
188d307f52SEvan Baconimport { NFCResponse } from './response';
19*8a424bebSJames Ideimport { FetchLike } from '../client.types';
208d307f52SEvan Bacon
218d307f52SEvan Baconconst CACHE_VERSION = 3;
228d307f52SEvan Bacon
238d307f52SEvan Baconconst lockPromiseForKey: Record<string, Promise<any>> = {};
248d307f52SEvan Baconconst unlockFunctionForKey: Record<string, any> = {};
258d307f52SEvan Bacon
268d307f52SEvan Bacon/**
278d307f52SEvan Bacon * Take out a lock. When this function returns (asynchronously),
288d307f52SEvan Bacon * you have the lock.
298d307f52SEvan Bacon * @param {string} key - The key to lock on. Anyone else who
308d307f52SEvan Bacon *   tries to lock on the same key will need to wait for it to
318d307f52SEvan Bacon *   be unlocked.
328d307f52SEvan Bacon */
338d307f52SEvan Baconasync function lock(key: string) {
348d307f52SEvan Bacon  if (!lockPromiseForKey[key]) {
358d307f52SEvan Bacon    lockPromiseForKey[key] = Promise.resolve();
368d307f52SEvan Bacon  }
378d307f52SEvan Bacon
388d307f52SEvan Bacon  const takeLockPromise = lockPromiseForKey[key];
398d307f52SEvan Bacon  lockPromiseForKey[key] = takeLockPromise.then(
408d307f52SEvan Bacon    () =>
418d307f52SEvan Bacon      new Promise((fulfill) => {
428d307f52SEvan Bacon        unlockFunctionForKey[key] = fulfill;
438d307f52SEvan Bacon      })
448d307f52SEvan Bacon  );
458d307f52SEvan Bacon
468d307f52SEvan Bacon  return takeLockPromise;
478d307f52SEvan Bacon}
488d307f52SEvan Bacon
498d307f52SEvan Bacon/**
508d307f52SEvan Bacon * Release a lock.
518d307f52SEvan Bacon * @param {string} key - The key to release the lock for.
528d307f52SEvan Bacon *   The next person in line will now be able to take out
538d307f52SEvan Bacon *   the lock for that key.
548d307f52SEvan Bacon */
558d307f52SEvan Baconfunction unlock(key: string) {
568d307f52SEvan Bacon  if (unlockFunctionForKey[key]) {
578d307f52SEvan Bacon    unlockFunctionForKey[key]();
588d307f52SEvan Bacon    delete unlockFunctionForKey[key];
598d307f52SEvan Bacon  }
608d307f52SEvan Bacon}
618d307f52SEvan Bacon
628d307f52SEvan Baconfunction md5(str: string) {
638d307f52SEvan Bacon  return crypto.createHash('md5').update(str).digest('hex');
648d307f52SEvan Bacon}
658d307f52SEvan Bacon
668d307f52SEvan Bacon// Since the boundary in FormData is random,
678d307f52SEvan Bacon// we ignore it for purposes of calculating
688d307f52SEvan Bacon// the cache key.
698d307f52SEvan Baconfunction getFormDataCacheKey(formData: FormData) {
708d307f52SEvan Bacon  const cacheKey = { ...formData };
718d307f52SEvan Bacon  const boundary = formData.getBoundary();
728d307f52SEvan Bacon
738d307f52SEvan Bacon  // @ts-expect-error
748d307f52SEvan Bacon  delete cacheKey._boundary;
758d307f52SEvan Bacon
768d307f52SEvan Bacon  const boundaryReplaceRegex = new RegExp(boundary, 'g');
778d307f52SEvan Bacon
788d307f52SEvan Bacon  // @ts-expect-error
798d307f52SEvan Bacon  cacheKey._streams = cacheKey._streams.map((s) => {
808d307f52SEvan Bacon    if (typeof s === 'string') {
818d307f52SEvan Bacon      return s.replace(boundaryReplaceRegex, '');
828d307f52SEvan Bacon    }
838d307f52SEvan Bacon
848d307f52SEvan Bacon    return s;
858d307f52SEvan Bacon  });
868d307f52SEvan Bacon
878d307f52SEvan Bacon  return cacheKey;
888d307f52SEvan Bacon}
898d307f52SEvan Bacon
908d307f52SEvan Baconfunction getBodyCacheKeyJson(body: any) {
918d307f52SEvan Bacon  if (!body) {
928d307f52SEvan Bacon    return body;
938d307f52SEvan Bacon  }
948d307f52SEvan Bacon  if (typeof body === 'string') {
958d307f52SEvan Bacon    return body;
968d307f52SEvan Bacon  }
978d307f52SEvan Bacon  if (body instanceof URLSearchParams) {
988d307f52SEvan Bacon    return body.toString();
998d307f52SEvan Bacon  }
1008d307f52SEvan Bacon  if (body instanceof fs.ReadStream) {
1018d307f52SEvan Bacon    return body.path;
1028d307f52SEvan Bacon  }
1038d307f52SEvan Bacon  if (body.toString && body.toString() === '[object FormData]') {
1048d307f52SEvan Bacon    return getFormDataCacheKey(body);
1058d307f52SEvan Bacon  }
1068d307f52SEvan Bacon  if (body instanceof Buffer) {
1078d307f52SEvan Bacon    return body.toString();
1088d307f52SEvan Bacon  }
1098d307f52SEvan Bacon
1108d307f52SEvan Bacon  throw new Error(
1118d307f52SEvan Bacon    'Unsupported body type. Supported body types are: string, number, undefined, null, url.URLSearchParams, fs.ReadStream, FormData'
1128d307f52SEvan Bacon  );
1138d307f52SEvan Bacon}
1148d307f52SEvan Bacon
1158d307f52SEvan Baconfunction getRequestCacheKey(req: any) {
1168d307f52SEvan Bacon  return {
1178d307f52SEvan Bacon    cache: req.cache,
1188d307f52SEvan Bacon    credentials: req.credentials,
1198d307f52SEvan Bacon    destination: req.destination,
1208d307f52SEvan Bacon    headers: req.headers,
1218d307f52SEvan Bacon    integrity: req.integrity,
1228d307f52SEvan Bacon    method: req.method,
1238d307f52SEvan Bacon    redirect: req.redirect,
1248d307f52SEvan Bacon    referrer: req.referrer,
1258d307f52SEvan Bacon    referrerPolicy: req.referrerPolicy,
1268d307f52SEvan Bacon    url: req.url,
1278d307f52SEvan Bacon    body: getBodyCacheKeyJson(req.body),
1288d307f52SEvan Bacon  };
1298d307f52SEvan Bacon}
1308d307f52SEvan Bacon
1318d307f52SEvan Baconfunction getCacheKey(requestArguments: any[]) {
1328d307f52SEvan Bacon  const resource = requestArguments[0];
1338d307f52SEvan Bacon  const init = requestArguments[1] || {};
1348d307f52SEvan Bacon
1358d307f52SEvan Bacon  const resourceCacheKeyJson =
1368d307f52SEvan Bacon    resource instanceof Request ? getRequestCacheKey(resource) : { url: resource };
1378d307f52SEvan Bacon
1388d307f52SEvan Bacon  const initCacheKeyJson = { ...init };
1398d307f52SEvan Bacon
1408d307f52SEvan Bacon  // @ts-ignore
1418d307f52SEvan Bacon  resourceCacheKeyJson.body = getBodyCacheKeyJson(resourceCacheKeyJson.body);
1428d307f52SEvan Bacon  initCacheKeyJson.body = getBodyCacheKeyJson(initCacheKeyJson.body);
1438d307f52SEvan Bacon
1448d307f52SEvan Bacon  delete initCacheKeyJson.agent;
1458d307f52SEvan Bacon
1468d307f52SEvan Bacon  return md5(JSON.stringify([resourceCacheKeyJson, initCacheKeyJson, CACHE_VERSION]));
1478d307f52SEvan Bacon}
1488d307f52SEvan Bacon
1498d307f52SEvan Baconexport function wrapFetchWithCache(
1508d307f52SEvan Bacon  fetch: FetchLike,
1518d307f52SEvan Bacon  cache: FileSystemCache
1528d307f52SEvan Bacon): (url: RequestInfo, init?: RequestInit | undefined) => Promise<Response> {
1538d307f52SEvan Bacon  async function getResponse(
1548d307f52SEvan Bacon    cache: FileSystemCache,
1558d307f52SEvan Bacon    url: RequestInfo,
1568d307f52SEvan Bacon    init?: RequestInit | undefined
1578d307f52SEvan Bacon  ) {
1588d307f52SEvan Bacon    const cacheKey = getCacheKey([url, init]);
1598d307f52SEvan Bacon    let cachedValue = await cache.get(cacheKey);
1608d307f52SEvan Bacon
1618d307f52SEvan Bacon    const ejectSelfFromCache = () => cache.remove(cacheKey);
1628d307f52SEvan Bacon
1638d307f52SEvan Bacon    if (cachedValue) {
1648d307f52SEvan Bacon      return new NFCResponse(
1658d307f52SEvan Bacon        cachedValue.bodyStream,
1668d307f52SEvan Bacon        cachedValue.metaData,
1678d307f52SEvan Bacon        ejectSelfFromCache,
1688d307f52SEvan Bacon        true
1698d307f52SEvan Bacon      );
1708d307f52SEvan Bacon    }
1718d307f52SEvan Bacon
1728d307f52SEvan Bacon    await lock(cacheKey);
1738d307f52SEvan Bacon    try {
1748d307f52SEvan Bacon      cachedValue = await cache.get(cacheKey);
1758d307f52SEvan Bacon      if (cachedValue) {
1768d307f52SEvan Bacon        return new NFCResponse(
1778d307f52SEvan Bacon          cachedValue.bodyStream,
1788d307f52SEvan Bacon          cachedValue.metaData,
1798d307f52SEvan Bacon          ejectSelfFromCache,
1808d307f52SEvan Bacon          true
1818d307f52SEvan Bacon        );
1828d307f52SEvan Bacon      }
1838d307f52SEvan Bacon
1848d307f52SEvan Bacon      const fetchResponse = await fetch(url, init);
1858d307f52SEvan Bacon      const serializedMeta = NFCResponse.serializeMetaFromNodeFetchResponse(fetchResponse);
1868d307f52SEvan Bacon
1878d307f52SEvan Bacon      const newlyCachedData = await cache.set(
1888d307f52SEvan Bacon        cacheKey,
1898d307f52SEvan Bacon        // @ts-expect-error
1908d307f52SEvan Bacon        fetchResponse.body,
1918d307f52SEvan Bacon        serializedMeta
1928d307f52SEvan Bacon      );
1938d307f52SEvan Bacon
1948d307f52SEvan Bacon      return new NFCResponse(
1958d307f52SEvan Bacon        newlyCachedData!.bodyStream,
1968d307f52SEvan Bacon        newlyCachedData!.metaData,
1978d307f52SEvan Bacon        ejectSelfFromCache,
1988d307f52SEvan Bacon        false
1998d307f52SEvan Bacon      );
2008d307f52SEvan Bacon    } finally {
2018d307f52SEvan Bacon      unlock(cacheKey);
2028d307f52SEvan Bacon    }
2038d307f52SEvan Bacon  }
2048d307f52SEvan Bacon  return (url: RequestInfo, init?: RequestInit | undefined) => getResponse(cache, url, init);
2058d307f52SEvan Bacon}
206