1/**
2 * Copyright (c) 2021 Expo, Inc.
3 * Copyright (c) 2020 mistval.
4 *
5 * This source code is licensed under the MIT license found in the
6 * LICENSE file in the root directory of this source tree.
7 *
8 * Based on https://github.com/mistval/node-fetch-cache/blob/9c40ddf786b0de22ce521d8bdaa6347bc44dd629/src/index.js#L1
9 * But with TypeScript support to fix Jest tests, and removed unused code.
10 */
11import crypto from 'crypto';
12import FormData from 'form-data';
13import fs from 'fs';
14import { Request, RequestInfo, RequestInit, Response } from 'node-fetch';
15import { URLSearchParams } from 'url';
16
17import { FileSystemCache } from './FileSystemCache';
18import { NFCResponse } from './response';
19import { FetchLike } from '../client.types';
20
21const CACHE_VERSION = 3;
22
23const lockPromiseForKey: Record<string, Promise<any>> = {};
24const unlockFunctionForKey: Record<string, any> = {};
25
26/**
27 * Take out a lock. When this function returns (asynchronously),
28 * you have the lock.
29 * @param {string} key - The key to lock on. Anyone else who
30 *   tries to lock on the same key will need to wait for it to
31 *   be unlocked.
32 */
33async function lock(key: string) {
34  if (!lockPromiseForKey[key]) {
35    lockPromiseForKey[key] = Promise.resolve();
36  }
37
38  const takeLockPromise = lockPromiseForKey[key];
39  lockPromiseForKey[key] = takeLockPromise.then(
40    () =>
41      new Promise((fulfill) => {
42        unlockFunctionForKey[key] = fulfill;
43      })
44  );
45
46  return takeLockPromise;
47}
48
49/**
50 * Release a lock.
51 * @param {string} key - The key to release the lock for.
52 *   The next person in line will now be able to take out
53 *   the lock for that key.
54 */
55function unlock(key: string) {
56  if (unlockFunctionForKey[key]) {
57    unlockFunctionForKey[key]();
58    delete unlockFunctionForKey[key];
59  }
60}
61
62function md5(str: string) {
63  return crypto.createHash('md5').update(str).digest('hex');
64}
65
66// Since the boundary in FormData is random,
67// we ignore it for purposes of calculating
68// the cache key.
69function getFormDataCacheKey(formData: FormData) {
70  const cacheKey = { ...formData };
71  const boundary = formData.getBoundary();
72
73  // @ts-expect-error
74  delete cacheKey._boundary;
75
76  const boundaryReplaceRegex = new RegExp(boundary, 'g');
77
78  // @ts-expect-error
79  cacheKey._streams = cacheKey._streams.map((s) => {
80    if (typeof s === 'string') {
81      return s.replace(boundaryReplaceRegex, '');
82    }
83
84    return s;
85  });
86
87  return cacheKey;
88}
89
90function getBodyCacheKeyJson(body: any) {
91  if (!body) {
92    return body;
93  }
94  if (typeof body === 'string') {
95    return body;
96  }
97  if (body instanceof URLSearchParams) {
98    return body.toString();
99  }
100  if (body instanceof fs.ReadStream) {
101    return body.path;
102  }
103  if (body.toString && body.toString() === '[object FormData]') {
104    return getFormDataCacheKey(body);
105  }
106  if (body instanceof Buffer) {
107    return body.toString();
108  }
109
110  throw new Error(
111    'Unsupported body type. Supported body types are: string, number, undefined, null, url.URLSearchParams, fs.ReadStream, FormData'
112  );
113}
114
115function getRequestCacheKey(req: any) {
116  return {
117    cache: req.cache,
118    credentials: req.credentials,
119    destination: req.destination,
120    headers: req.headers,
121    integrity: req.integrity,
122    method: req.method,
123    redirect: req.redirect,
124    referrer: req.referrer,
125    referrerPolicy: req.referrerPolicy,
126    url: req.url,
127    body: getBodyCacheKeyJson(req.body),
128  };
129}
130
131function getCacheKey(requestArguments: any[]) {
132  const resource = requestArguments[0];
133  const init = requestArguments[1] || {};
134
135  const resourceCacheKeyJson =
136    resource instanceof Request ? getRequestCacheKey(resource) : { url: resource };
137
138  const initCacheKeyJson = { ...init };
139
140  // @ts-ignore
141  resourceCacheKeyJson.body = getBodyCacheKeyJson(resourceCacheKeyJson.body);
142  initCacheKeyJson.body = getBodyCacheKeyJson(initCacheKeyJson.body);
143
144  delete initCacheKeyJson.agent;
145
146  return md5(JSON.stringify([resourceCacheKeyJson, initCacheKeyJson, CACHE_VERSION]));
147}
148
149export function wrapFetchWithCache(
150  fetch: FetchLike,
151  cache: FileSystemCache
152): (url: RequestInfo, init?: RequestInit | undefined) => Promise<Response> {
153  async function getResponse(
154    cache: FileSystemCache,
155    url: RequestInfo,
156    init?: RequestInit | undefined
157  ) {
158    const cacheKey = getCacheKey([url, init]);
159    let cachedValue = await cache.get(cacheKey);
160
161    const ejectSelfFromCache = () => cache.remove(cacheKey);
162
163    if (cachedValue) {
164      return new NFCResponse(
165        cachedValue.bodyStream,
166        cachedValue.metaData,
167        ejectSelfFromCache,
168        true
169      );
170    }
171
172    await lock(cacheKey);
173    try {
174      cachedValue = await cache.get(cacheKey);
175      if (cachedValue) {
176        return new NFCResponse(
177          cachedValue.bodyStream,
178          cachedValue.metaData,
179          ejectSelfFromCache,
180          true
181        );
182      }
183
184      const fetchResponse = await fetch(url, init);
185      const serializedMeta = NFCResponse.serializeMetaFromNodeFetchResponse(fetchResponse);
186
187      const newlyCachedData = await cache.set(
188        cacheKey,
189        // @ts-expect-error
190        fetchResponse.body,
191        serializedMeta
192      );
193
194      return new NFCResponse(
195        newlyCachedData!.bodyStream,
196        newlyCachedData!.metaData,
197        ejectSelfFromCache,
198        false
199      );
200    } finally {
201      unlock(cacheKey);
202    }
203  }
204  return (url: RequestInfo, init?: RequestInit | undefined) => getResponse(cache, url, init);
205}
206