-
Notifications
You must be signed in to change notification settings - Fork 264
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #133 from Shopify/fd-subrequest-cache
Subrequest cache
- Loading branch information
Showing
15 changed files
with
1,107 additions
and
1,617 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,177 @@ | ||
import type {CachingStrategy} from './strategies'; | ||
import {CacheShort, generateCacheControlHeader} from './strategies'; | ||
|
||
function logCacheApiStatus(status: string | null, url: string) { | ||
// // eslint-disable-next-line no-console | ||
// console.log('\n' + status, url); | ||
} | ||
|
||
function getCacheControlSetting( | ||
userCacheOptions?: CachingStrategy, | ||
options?: CachingStrategy, | ||
): CachingStrategy { | ||
if (userCacheOptions && options) { | ||
return { | ||
...userCacheOptions, | ||
...options, | ||
}; | ||
} else { | ||
return userCacheOptions || CacheShort(); | ||
} | ||
} | ||
|
||
function generateDefaultCacheControlHeader( | ||
userCacheOptions?: CachingStrategy, | ||
): string { | ||
return generateCacheControlHeader(getCacheControlSetting(userCacheOptions)); | ||
} | ||
|
||
/** | ||
* Get an item from the cache. If a match is found, returns a tuple | ||
* containing the `JSON.parse` version of the response as well | ||
* as the response itself so it can be checked for staleness. | ||
*/ | ||
async function getItem( | ||
cache: Cache, | ||
request: Request, | ||
): Promise<Response | undefined> { | ||
if (!cache) return; | ||
|
||
const response = await cache.match(request); | ||
if (!response) { | ||
logCacheApiStatus('MISS', request.url); | ||
return; | ||
} | ||
|
||
logCacheApiStatus('HIT', request.url); | ||
|
||
return response; | ||
} | ||
|
||
/** | ||
* Put an item into the cache. | ||
*/ | ||
async function setItem( | ||
cache: Cache, | ||
request: Request, | ||
response: Response, | ||
userCacheOptions: CachingStrategy, | ||
) { | ||
if (!cache) return; | ||
|
||
/** | ||
* We are manually managing staled request by adding this workaround. | ||
* Why? cache control header support is dependent on hosting platform | ||
* | ||
* For example: | ||
* | ||
* Cloudflare's Cache API does not support `stale-while-revalidate`. | ||
* Cloudflare cache control header has a very odd behaviour. | ||
* Say we have the following cache control header on a request: | ||
* | ||
* public, max-age=15, stale-while-revalidate=30 | ||
* | ||
* When there is a cache.match HIT, the cache control header would become | ||
* | ||
* public, max-age=14400, stale-while-revalidate=30 | ||
* | ||
* == `stale-while-revalidate` workaround == | ||
* Update response max-age so that: | ||
* | ||
* max-age = max-age + stale-while-revalidate | ||
* | ||
* For example: | ||
* | ||
* public, max-age=1, stale-while-revalidate=9 | ||
* | | ||
* V | ||
* public, max-age=10, stale-while-revalidate=9 | ||
* | ||
* Store the following information in the response header: | ||
* | ||
* cache-put-date - UTC time string of when this request is PUT into cache | ||
* | ||
* Note on `cache-put-date`: The `response.headers.get('date')` isn't static. I am | ||
* not positive what date this is returning but it is never over 500 ms | ||
* after subtracting from the current timestamp. | ||
* | ||
* `isStale` function will use the above information to test for stale-ness of a cached response | ||
*/ | ||
|
||
const cacheControl = getCacheControlSetting(userCacheOptions); | ||
|
||
// The padded cache-control to mimic stale-while-revalidate | ||
request.headers.set( | ||
'cache-control', | ||
generateDefaultCacheControlHeader( | ||
getCacheControlSetting(cacheControl, { | ||
maxAge: | ||
(cacheControl.maxAge || 0) + (cacheControl.staleWhileRevalidate || 0), | ||
}), | ||
), | ||
); | ||
// The cache-control we want to set on response | ||
const cacheControlString = generateDefaultCacheControlHeader( | ||
getCacheControlSetting(cacheControl), | ||
); | ||
|
||
// CF will override cache-control, so we need to keep a non-modified real-cache-control | ||
// cache-control is still necessary for mini-oxygen | ||
response.headers.set('cache-control', cacheControlString); | ||
response.headers.set('real-cache-control', cacheControlString); | ||
response.headers.set('cache-put-date', new Date().toUTCString()); | ||
|
||
logCacheApiStatus('PUT', request.url); | ||
await cache.put(request, response); | ||
} | ||
|
||
async function deleteItem(cache: Cache, request: Request) { | ||
if (!cache) return; | ||
|
||
logCacheApiStatus('DELETE', request.url); | ||
await cache.delete(request); | ||
} | ||
|
||
/** | ||
* Manually check the response to see if it's stale. | ||
*/ | ||
function isStale(request: Request, response: Response) { | ||
const responseDate = response.headers.get('cache-put-date'); | ||
const cacheControl = response.headers.get('real-cache-control'); | ||
let responseMaxAge = 0; | ||
|
||
if (cacheControl) { | ||
const maxAgeMatch = cacheControl.match(/max-age=(\d*)/); | ||
if (maxAgeMatch && maxAgeMatch.length > 1) { | ||
responseMaxAge = parseFloat(maxAgeMatch[1]); | ||
} | ||
} | ||
|
||
if (!responseDate) { | ||
return false; | ||
} | ||
|
||
const ageInMs = | ||
new Date().valueOf() - new Date(responseDate as string).valueOf(); | ||
const age = ageInMs / 1000; | ||
|
||
const result = age > responseMaxAge; | ||
|
||
if (result) { | ||
logCacheApiStatus('STALE', request.url); | ||
} | ||
|
||
return result; | ||
} | ||
|
||
/** | ||
* | ||
* @private | ||
*/ | ||
export const CacheAPI = { | ||
get: getItem, | ||
set: setItem, | ||
delete: deleteItem, | ||
generateDefaultCacheControlHeader, | ||
isStale, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,146 @@ | ||
import {CacheShort, CachingStrategy} from './strategies'; | ||
import { | ||
deleteItemFromCache, | ||
getItemFromCache, | ||
isStale, | ||
setItemInCache, | ||
} from './subrequest'; | ||
|
||
export type FetchCacheOptions = { | ||
cache?: CachingStrategy; | ||
cacheInstance?: Cache; | ||
cacheKey?: string | readonly unknown[]; | ||
shouldCacheResponse?: (body: any, response: Response) => boolean; | ||
waitUntil?: ExecutionContext['waitUntil']; | ||
returnType?: 'json' | 'text' | 'arrayBuffer' | 'blob'; | ||
}; | ||
|
||
function serializeResponse(body: any, response: Response) { | ||
return [ | ||
body, | ||
{ | ||
status: response.status, | ||
statusText: response.statusText, | ||
headers: Array.from(response.headers.entries()), | ||
}, | ||
]; | ||
} | ||
|
||
/** | ||
* `fetch` equivalent that stores responses in cache. | ||
* Useful for calling third-party APIs that need to be cached. | ||
* @public | ||
*/ | ||
export async function fetchWithServerCache( | ||
url: string, | ||
requestInit: Request | RequestInit, | ||
{ | ||
cacheInstance, | ||
cache: cacheOptions, | ||
cacheKey = [url, requestInit], | ||
shouldCacheResponse = () => true, | ||
waitUntil, | ||
returnType = 'json', | ||
}: FetchCacheOptions = {}, | ||
): Promise<readonly [any, Response]> { | ||
const doFetch = async () => { | ||
const response = await fetch(url, requestInit); | ||
let data; | ||
|
||
try { | ||
data = await response[returnType](); | ||
} catch { | ||
data = await response.text(); | ||
} | ||
|
||
return [data, response] as const; | ||
}; | ||
|
||
if (!cacheInstance || !cacheKey) return doFetch(); | ||
|
||
const key = [ | ||
// '__HYDROGEN_CACHE_ID__', // TODO purgeQueryCacheOnBuild | ||
...(typeof cacheKey === 'string' ? [cacheKey] : cacheKey), | ||
]; | ||
|
||
const cachedItem = await getItemFromCache(cacheInstance, key); | ||
|
||
if (cachedItem) { | ||
const [value, cacheResponse] = cachedItem; | ||
|
||
// collectQueryCacheControlHeaders( | ||
// request, | ||
// key, | ||
// response.headers.get('cache-control'), | ||
// ); | ||
|
||
/** | ||
* Important: Do this async | ||
*/ | ||
if (isStale(key, cacheResponse)) { | ||
const lockKey = ['lock', ...(typeof key === 'string' ? [key] : key)]; | ||
|
||
// Run revalidation asynchronously | ||
const revalidatingPromise = getItemFromCache(cacheInstance, lockKey).then( | ||
async (lockExists) => { | ||
if (lockExists) return; | ||
|
||
await setItemInCache( | ||
cacheInstance, | ||
lockKey, | ||
true, | ||
CacheShort({maxAge: 10}), | ||
); | ||
|
||
try { | ||
const [body, response] = await doFetch(); | ||
|
||
if (shouldCacheResponse(body, response)) { | ||
await setItemInCache( | ||
cacheInstance, | ||
key, | ||
serializeResponse(body, response), | ||
cacheOptions, | ||
); | ||
} | ||
} catch (e: any) { | ||
// eslint-disable-next-line no-console | ||
console.error(`Error generating async response: ${e.message}`); | ||
} finally { | ||
await deleteItemFromCache(cacheInstance, lockKey); | ||
} | ||
}, | ||
); | ||
|
||
// Asynchronously wait for it in workers | ||
waitUntil?.(revalidatingPromise); | ||
} | ||
|
||
const [body, init] = value; | ||
return [body, new Response(body, init)]; | ||
} | ||
|
||
const [body, response] = await doFetch(); | ||
|
||
/** | ||
* Important: Do this async | ||
*/ | ||
if (shouldCacheResponse(body, response)) { | ||
const setItemInCachePromise = setItemInCache( | ||
cacheInstance, | ||
key, | ||
serializeResponse(body, response), | ||
cacheOptions, | ||
); | ||
|
||
waitUntil?.(setItemInCachePromise); | ||
} | ||
|
||
// collectQueryCacheControlHeaders( | ||
// request, | ||
// key, | ||
// generateSubRequestCacheControlHeader(resolvedQueryOptions?.cache) | ||
// ); | ||
|
||
return [body, response]; | ||
} |
Oops, something went wrong.