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