Skip to content

Commit

Permalink
Merge pull request #133 from Shopify/fd-subrequest-cache
Browse files Browse the repository at this point in the history
Subrequest cache
  • Loading branch information
frandiox committed Nov 9, 2022
2 parents 5044d7a + 162e2d4 commit 102f6dd
Show file tree
Hide file tree
Showing 15 changed files with 1,107 additions and 1,617 deletions.
1,796 changes: 218 additions & 1,578 deletions package-lock.json

Large diffs are not rendered by default.

177 changes: 177 additions & 0 deletions packages/@hydrogen/remix/cache/api.ts
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,
};
146 changes: 146 additions & 0 deletions packages/@hydrogen/remix/cache/fetch.ts
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];
}
Loading

0 comments on commit 102f6dd

Please sign in to comment.