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
51 changes: 51 additions & 0 deletions .changeset/swift-glasses-remain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
'@shopify/hydrogen': patch
---

Add an experimental `withCache_unstable` utility similar to `useQuery` from Hydrogen v1. To setup the utility, update your `server.ts`:

```diff
- const {storefront} = createStorefrontClient({
+ const {storefront, withCache_unstable} = createStorefrontClient({
...
});

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

Then use the utility within your loaders:

```ts
export async function loader({
context: {storefront, withCache_unstable},
}: LoaderArgs) {
const data = await withCache_unstable(
'test-with-cache',
async () => {
const result = await fetch('https://www.some.com/api');
if (result.ok) {
return result.json();
} else {
throw new Error('Error: ' + result.status);
}
},
{
strategy: storefront.CacheLong(),
},
);

return json({data});
}
```

The utility is unstable and subject to change before stabalizing in the 2023.04 release.
10 changes: 5 additions & 5 deletions packages/hydrogen/README.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
<div align="center">

# Hydrogen

[![MIT License](https://img.shields.io/github/license/shopify/hydrogen)](LICENSE.md)
[![npm downloads](https://img.shields.io/npm/dm/@shopify/hydrogen.svg?sanitize=true)](https://npmcharts.com/compare/@shopify/hydrogen?minimal=true)

Hydrogen is Shopify’s stack for headless commerce. It provides a set of tools, utilities, and best-in-class examples for building dynamic and performant commerce applications. Hydrogen is designed to dovetail with [Remix](https://remix.run/), Shopify’s full stack web framework, but it also provides a React library portable to other supporting frameworks.


**Create a Hydrogen app**

```bash
npm create @shopify/hydrogen@latest
```
```bash
npm create @shopify/hydrogen@latest
```

</div>

- Interested in contributing to Hydrogen? [Read our contributing guide](../../CONTRIBUTING.md)
Expand Down
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: createWithCacheUtility_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 createWithCacheUtil_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 createWithCacheUtil_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
Loading