Move Storefront queries onto @shopify/hydrogen preview client

Replaces the hand-rolled shopifyFetch wrapper and raw query strings with
the hydrogen preview package (0.0.0-preview-116d5d7-20260730141607), the
same build pinned in hydrogen-preview/.

- services/shopify/client: createStorefrontClient({type: "public"}) with a
  static request context. Two module-scoped clients — uncached for carts,
  products and customers; revalidate-3600 for shop policies — using the
  client's custom fetch option to carry Next's caching hints.
- graphql/*: every document wrapped in gql(), fragments composed via the
  second argument instead of string interpolation, and $country/$language
  declared with @inContext so hydrogen injects them.
- shopifyFetch keeps its {query, variables} call shape so call sites are
  unchanged, but is now generic over the document, so data is inferred. It
  re-raises GraphQL errors, which hydrogen returns rather than throws.

Env var names, the 2025-07 API version pin, and the client-side fetching
architecture are unchanged.

Turning on type coverage surfaced real defects, not just annotations:

- hydrogen gql check caught $discountCodes: [String!] used where the field
  requires [String!]!.
- Cart and customer mutation payloads are nullable and were dereferenced
  unconditionally, so a failed mutation threw a TypeError. Adds a shared
  unwrapCartPayload helper (collapsing five copies of the same userErrors
  check) and explicit null handling in the customer service, so a null
  payload reads as an error rather than success with no errors.
- Search results are a Product | Page | Article union; adds __typename to
  the queries and narrows on it.
- Widens nullable fields (altText, image, customer.email, totalTaxAmount)
  in the domain interfaces and in the structural duplicates some
  components declare locally.

Adds a typecheck script (tsc --noEmit && hydrogen gql check); it passes
with 0 errors. Two deprecation warnings are left alone as acting on them
would change behaviour: ProductOption.values and CartCost.totalTaxAmount.

Verified against mock.shop: build prerenders the policy pages through the
cached client, and in the browser the product grid, search with facets,
collection filter round-trip, add-to-cart and quantity update all work.
Customer account flows are untested — they need real credentials.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JaxgqPbFxLsSuLPZom2kdC
This commit is contained in:
Rami Bitar
2026-08-08 10:24:38 -04:00
co-authored by Claude Opus 5
parent 95b191eeb8
commit f8c3ef9e0e
21 changed files with 468 additions and 215 deletions
+108
View File
@@ -0,0 +1,108 @@
// Shopify Storefront API access, backed by `@shopify/hydrogen` (preview build).
//
// The package supplies the transport (`createStorefrontClient`) and the typed
// query documents (`gql`, see graphql/*.ts). `shopifyFetch` keeps the call
// shape the rest of the app already uses — `{ query, variables }` in, `{ data }`
// out, throwing on GraphQL errors — but now infers `data` from the document.
import {
createShopifyRequestContext,
createStorefrontClient,
StorefrontApiError,
type AnyStorefrontQueryString,
type StorefrontApi,
} from '@shopify/hydrogen';
const SHOPIFY_STORE_DOMAIN = process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN;
const SHOPIFY_STOREFRONT_ACCESS_TOKEN =
process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN;
const SHOPIFY_API_VERSION =
process.env.NEXT_PUBLIC_SHOPIFY_API_VERSION || '2025-07';
const SHOPIFY_STOREFRONT_API_URL = `https://${SHOPIFY_STORE_DOMAIN}/api/${SHOPIFY_API_VERSION}/graphql.json`;
// No incoming request and no buyer context: these clients are module-scoped and
// serve both server rendering and the browser-side hooks, so they must not close
// over per-request state. `$country`/`$language` are injected from this i18n.
const requestContext = createShopifyRequestContext({
request: { headers: new Headers() },
i18n: { country: 'US', language: 'EN' },
});
// Hydrogen calls `fetch(url, init, cacheOptions)`; Next's caching hints ride
// along on `init`, which is how the two ways of caching get to coexist.
const fetchWith = (overrides: RequestInit): typeof globalThis.fetch =>
((url, init) =>
globalThis.fetch(url, { ...init, ...overrides })) as typeof globalThis.fetch;
const config = {
storeDomain: SHOPIFY_STORE_DOMAIN!,
apiVersion: SHOPIFY_API_VERSION,
// `mock.shop` and other tokenless storefronts leave this unset.
publicStorefrontToken: SHOPIFY_STOREFRONT_ACCESS_TOKEN,
};
/**
* Default client. Uncached, because carts and customer reads must never serve a
* stale response.
*/
export const storefront = createStorefrontClient({
type: 'public',
requestContext,
config: { ...config, fetch: fetchWith({ cache: 'no-store' }) },
});
/**
* Client for catalogue data that changes rarely (shop policies, and anything
* else safe to serve from Next's data cache for an hour).
*/
export const cachedStorefront = createStorefrontClient({
type: 'public',
requestContext,
config: { ...config, fetch: fetchWith({ next: { revalidate: 3600 } }) },
});
interface ShopifyFetchArgs<TDoc extends AnyStorefrontQueryString> {
query: TDoc;
variables?: StorefrontApi.VariablesOf<TDoc>;
/** Serve from Next's data cache instead of hitting Shopify every time. */
cache?: boolean;
}
/**
* Runs a `gql()` document and returns its data.
*
* Hydrogen returns GraphQL errors rather than throwing them (a 200 with partial
* data is still a valid response), so this re-raises them to preserve the
* fail-loudly behaviour the callers were written against. Transport failures
* already throw as `StorefrontApiError`.
*/
export async function shopifyFetch<TDoc extends AnyStorefrontQueryString>({
query,
variables,
cache = false,
}: ShopifyFetchArgs<TDoc>): Promise<{
data: StorefrontApi.ResultOf<TDoc>;
}> {
const client = cache ? cachedStorefront : storefront;
try {
const { data, errors } = await client.graphql(query, {
variables,
} as never);
if (errors?.length) {
console.error('Shopify API errors:', errors);
throw new Error(`Shopify GraphQL errors: ${JSON.stringify(errors)}`);
}
return { data: data as StorefrontApi.ResultOf<TDoc> };
} catch (error) {
if (error instanceof StorefrontApiError) {
console.error('Shopify fetch error:', error.toJSON());
} else {
console.error('Shopify fetch error:', error);
}
throw error;
}
}
export { SHOPIFY_STORE_DOMAIN, SHOPIFY_STOREFRONT_API_URL };