Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PoC withCache utility #600

Merged
merged 14 commits into from
Mar 29, 2023
52 changes: 52 additions & 0 deletions .changeset/swift-glasses-remain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
'@shopify/hydrogen': patch
---

Add an experimental `createWithCache_unstable` utility, which creates a function similar to `useQuery` from Hydrogen v1. Use this utility to query third-party APIs and apply custom cache options.

To setup the utility, update your `server.ts`:

```js
import {
createStorefrontClient,
createWithCache_unstable,
CacheLong,
} from '@shopify/hydrogen';

// ...

const cache = await caches.open('hydrogen');
const withCache = createWithCache_unstable({cache, waitUntil});

// Create custom utilities to query third-party APIs:
const fetchMyCMS = (query) => {
// Prefix the cache key and make it unique based on arguments.
return withCache(['my-cms', query], CacheLong(), () => {
const cmsData = await (await fetch('my-cms.com/api', {
method: 'POST',
body: query
})).json();

const nextPage = (await fetch('my-cms.com/api', {
method: 'POST',
body: cmsData1.nextPageQuery,
})).json();

return {...cmsData, nextPage}
});
};

const handleRequest = createRequestHandler({
build: remixBuild,
mode: process.env.NODE_ENV,
getLoadContext: () => ({
session,
waitUntil,
storefront,
env,
fetchMyCMS,
}),
});
```

**Note:** The utility is unstable and subject to change before stabalizing in the 2023.04 release.
132 changes: 80 additions & 52 deletions packages/hydrogen/src/cache/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,37 @@ import {hashKey} from '../utils/hash.js';
import {CacheShort, CachingStrategy} from './strategies';
import {getItemFromCache, setItemInCache, isStale} from './sub-request';

export type CacheKey = string | readonly unknown[];

export type WithCacheOptions<T = unknown> = {
strategy?: CachingStrategy | null;
cacheInstance?: Cache;
shouldCacheResult?: (value: T) => boolean;
waitUntil?: ExecutionContext['waitUntil'];
};

export type FetchCacheOptions = {
cache?: CachingStrategy;
cacheInstance?: Cache;
cacheKey?: string | readonly unknown[];
cacheKey?: CacheKey;
shouldCacheResponse?: (body: any, response: Response) => boolean;
waitUntil?: ExecutionContext['waitUntil'];
returnType?: 'json' | 'text' | 'arrayBuffer' | 'blob';
};

function serializeResponse(body: any, response: Response) {
function toSerializableResponse(body: any, response: Response) {
return [
body,
{
status: response.status,
statusText: response.statusText,
headers: Array.from(response.headers.entries()),
},
];
] satisfies [any, ResponseInit];
}

function fromSerializableResponse([body, init]: [any, ResponseInit]) {
return [body, new Response(body, init)] as const;
}

// Check if the response body has GraphQL errors
Expand All @@ -33,41 +46,17 @@ export const checkGraphQLErrors = (body: any) => !body?.errors;
// https://github.com/Shopify/oxygen-platform/issues/625
const swrLock = new Set<string>();

/**
* `fetch` equivalent that stores responses in cache.
* Useful for calling third-party APIs that need to be cached.
* @private
*/
export async function fetchWithServerCache(
url: string,
requestInit: Request | RequestInit,
export async function runWithCache<T = unknown>(
cacheKey: CacheKey,
actionFn: () => T | Promise<T>,
{
strategy = CacheShort(),
cacheInstance,
cache: cacheOptions,
cacheKey = [url, requestInit],
shouldCacheResponse = () => true,
shouldCacheResult = () => true,
waitUntil,
returnType = 'json',
}: FetchCacheOptions = {},
): Promise<readonly [any, Response]> {
if (!cacheOptions && (!requestInit.method || requestInit.method === 'GET')) {
cacheOptions = CacheShort();
}

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 || !cacheOptions) return doFetch();
}: WithCacheOptions<T>,
): Promise<T> {
if (!cacheInstance || !strategy) return actionFn();

const key = hashKey([
// '__HYDROGEN_CACHE_ID__', // TODO purgeQueryCacheOnBuild
Expand All @@ -78,23 +67,18 @@ export async function fetchWithServerCache(
// console.log('--- Cache', cachedItem ? 'HIT' : 'MISS');

if (cachedItem) {
const [cachedValue, cacheInfo] = cachedItem;
const [cachedResult, cacheInfo] = cachedItem;

if (!swrLock.has(key) && isStale(key, cacheInfo)) {
swrLock.add(key);

// Important: Run revalidation asynchronously.
const revalidatingPromise = Promise.resolve().then(async () => {
try {
const [body, response] = await doFetch();

if (shouldCacheResponse(body, response)) {
await setItemInCache(
cacheInstance,
key,
serializeResponse(body, response),
cacheOptions,
);
const result = await actionFn();

if (shouldCacheResult(result)) {
await setItemInCache(cacheInstance, key, result, strategy);
}
} catch (error: any) {
if (error.message) {
Expand All @@ -111,25 +95,69 @@ export async function fetchWithServerCache(
waitUntil?.(revalidatingPromise);
}

const [body, init] = cachedValue;
return [body, new Response(body, init)];
return cachedResult;
}

const [body, response] = await doFetch();
const result = await actionFn();

/**
* Important: Do this async
*/
if (shouldCacheResponse(body, response)) {
if (shouldCacheResult(result)) {
const setItemInCachePromise = setItemInCache(
cacheInstance,
key,
serializeResponse(body, response),
cacheOptions,
result,
strategy,
);

waitUntil?.(setItemInCachePromise);
}

return [body, response];
return result;
}

/**
* `fetch` equivalent that stores responses in cache.
* Useful for calling third-party APIs that need to be cached.
* @private
*/
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]> {
if (!cacheOptions && (!requestInit.method || requestInit.method === 'GET')) {
cacheOptions = CacheShort();
}

return runWithCache(
cacheKey,
async () => {
const response = await fetch(url, requestInit);
let data;

try {
data = await response[returnType]();
} catch {
data = await response.text();
}

return toSerializableResponse(data, response);
},
{
cacheInstance,
waitUntil,
strategy: cacheOptions ?? null,
shouldCacheResult: (result) =>
shouldCacheResponse(...fromSerializableResponse(result)),
},
).then(fromSerializableResponse);
}
8 changes: 7 additions & 1 deletion packages/hydrogen/src/cache/sub-request.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {parseJSON} from '../utils/parse-json';
import {CacheAPI} from './api';
import {
CacheShort,
Expand Down Expand Up @@ -48,7 +49,12 @@ export async function getItemFromCache(
return;
}

return [await response.json(), response];
const text = await response.text();
try {
return [parseJSON(text), response];
} catch {
return [text, response];
}
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/hydrogen/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './storefront';
export * from './with-cache';
export {
CacheCustom,
CacheLong,
Expand Down
17 changes: 6 additions & 11 deletions packages/hydrogen/src/routing/graphiql.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,17 @@
import type {LoaderArgs} from '@remix-run/server-runtime';
import type {I18nBase, StorefrontClient} from '../storefront';
import type {Storefront} from '../storefront';

export function graphiqlLoader(
{context} = {} as LoaderArgs & {
context: LoaderArgs['context'] & StorefrontClient<I18nBase>;
},
) {
if (!context?.storefront) {
export function graphiqlLoader({context}: LoaderArgs) {
const storefront = context?.storefront as Storefront | undefined;
if (!storefront) {
throw new Error(
`GraphiQL: Hydrogen's storefront client must be injected in the loader context.`,
);
}

const url = context.storefront.getApiUrl();
const url = storefront.getApiUrl();
const accessToken =
context.storefront.getPublicTokenHeaders()[
'X-Shopify-Storefront-Access-Token'
];
storefront.getPublicTokenHeaders()['X-Shopify-Storefront-Access-Token'];

return new Response(
`
Expand Down
46 changes: 46 additions & 0 deletions packages/hydrogen/src/with-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {type CacheKey, runWithCache} from './cache/fetch';
import type {CachingStrategy} from './cache/strategies';

/**
* Creates a utility function that executes an asynchronous operation
* like `fetch` and caches the result according to the strategy provided.
* Use this to call any third-party APIs from loaders or actions.
* By default, it uses the `CacheShort` strategy.
*
* Example:
*
* ```js
* // In your app's `server.ts` file:
* createRequestHandler({
* /* ... *\/,
* getLoadContext: () => ({ withCache: createWithCache_unstable({cache, waitUntil}) })
* });
*
* // In your route loaders:
* import {CacheShort} from '@shopify/hydrogen';
* export async function loader ({context: {withCache}}) {
* const data = await withCache('my-unique-key', CacheShort(), () => {
* return fetch('https://example.com/api').then(res => res.json());
* });
* ```
*/
export function createWithCache_unstable({
cache,
waitUntil,
}: {
cache: Cache;
waitUntil: ExecutionContext['waitUntil'];
}) {
return <T = unknown>(
cacheKey: CacheKey,
strategy: CachingStrategy,
actionFn: () => T | Promise<T>,
) =>
runWithCache<T>(cacheKey, actionFn, {
strategy,
cacheInstance: cache,
waitUntil,
});
}

export type WithCache = ReturnType<typeof createWithCache_unstable>;
2 changes: 1 addition & 1 deletion templates/demo-store/remix.env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/// <reference types="@shopify/remix-oxygen" />
/// <reference types="@shopify/oxygen-workers-types" />

import type {WithCache} from '@shopify/hydrogen';
import type {Storefront} from '~/lib/type';
import type {HydrogenSession} from '~/lib/session.server';

Expand Down Expand Up @@ -32,7 +33,6 @@ declare module '@shopify/remix-oxygen' {
waitUntil: ExecutionContext['waitUntil'];
session: HydrogenSession;
storefront: Storefront;
cache: Cache;
env: Env;
}
}
Expand Down
7 changes: 6 additions & 1 deletion templates/demo-store/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,12 @@ export default {
const handleRequest = createRequestHandler({
build: remixBuild,
mode: process.env.NODE_ENV,
getLoadContext: () => ({cache, session, waitUntil, storefront, env}),
getLoadContext: () => ({
session,
waitUntil,
storefront,
env,
}),
});

const response = await handleRequest(request);
Expand Down