Replace @shopify/hydrogen with direct Storefront API queries

Vercel's 'Hydrogen (v1)' preset matches any project depending on
@shopify/hydrogen and runs its v1 builder, which fails looking for
dist/worker. We only used the preview package as a Storefront API client,
so talk to the API directly instead:

- services/shopify/client.ts: plain fetch to /api/{version}/graphql.json
  with the public token header, @inContext country/language defaults, and
  the same graphql() / unwrapStorefrontResult API as before
- graphql/gql.ts: small helper that joins a document with its fragments
  (de-duplicated); the queries themselves are unchanged
- Explicit response types at each call site in place of the generated
  gql.tada types; local ProductFilter input type
- Drop @shopify/hydrogen, graphql, the TS plugin and 'hydrogen gql check'
- Copy no longer mentions Hydrogen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Rami Bitar
2026-10-03 15:28:02 -04:00
co-authored by Claude Opus 5.5
parent d33a6894f3
commit 6fcfe59d0d
20 changed files with 273 additions and 249 deletions
+50 -23
View File
@@ -3,7 +3,6 @@
// These are plain async functions with no React imports, so they can be called
// from the Hono server as well as from the TanStack Query options in
// hooks/use-shopify-*.ts, which re-export them.
import type { StorefrontApi } from '@shopify/hydrogen';
import { storefront, unwrapStorefrontResult } from '@/services/shopify/client';
import {
GET_PRODUCTS_QUERY,
@@ -19,6 +18,19 @@ import {
SEARCH_SUGGESTIONS_QUERY,
} from '@/graphql/search';
// Storefront API connection shape (`edges { node }` + `pageInfo`).
interface Connection<T> {
edges: Array<{ node: T }>;
pageInfo?: { hasNextPage: boolean; endCursor: string | null } | null;
}
// `search` returns a union; `types: PRODUCT` narrows it, `__typename` proves it.
type SearchNode<T> = (T & { __typename: 'Product' }) | { __typename: string };
const isProductNode = <T>(
node: SearchNode<T>
): node is T & { __typename: 'Product' } => node.__typename === 'Product';
// Optional fields are `| null` rather than just optional: the Storefront API
// returns explicit nulls, and the typed `gql()` documents now surface that.
interface ProductImage {
@@ -150,9 +162,10 @@ export async function getProductsPage({
reverse = false,
}: UseProductsOptions = {}): Promise<ProductsPage> {
const data = unwrapStorefrontResult(
await storefront.graphql(GET_PRODUCTS_QUERY, {
variables: { first, after, query, sortKey, reverse },
}),
await storefront.graphql<{ products: Connection<Product> }>(
GET_PRODUCTS_QUERY,
{ variables: { first, after, query, sortKey, reverse } }
),
'GetProducts'
);
@@ -168,7 +181,9 @@ export async function getProductsPage({
// Fetch a single product by handle
export async function getProduct(handle: string): Promise<Product | null> {
const data = unwrapStorefrontResult(
await storefront.graphql(GET_PRODUCT_QUERY, { variables: { handle } }),
await storefront.graphql<{ product: Product | null }>(GET_PRODUCT_QUERY, {
variables: { handle },
}),
'GetProduct'
);
@@ -178,9 +193,10 @@ export async function getProduct(handle: string): Promise<Product | null> {
// Fetch product recommendations
export async function getProductRecommendations(productId: string): Promise<Product[]> {
const data = unwrapStorefrontResult(
await storefront.graphql(QUERY_PRODUCT_RECOMMENDATIONS, {
variables: { productId },
}),
await storefront.graphql<{ productRecommendations: Product[] | null }>(
QUERY_PRODUCT_RECOMMENDATIONS,
{ variables: { productId } }
),
'GetProductRecommendations'
);
@@ -245,7 +261,10 @@ export interface ProductFilterFacet {
// Fetch all collections
export async function getCollections(first = 50): Promise<Collection[]> {
const data = unwrapStorefrontResult(
await storefront.graphql(GET_COLLECTIONS_QUERY, { variables: { first } }),
await storefront.graphql<{ collections: Connection<Collection> }>(
GET_COLLECTIONS_QUERY,
{ variables: { first } }
),
'GetCollections'
);
@@ -277,7 +296,13 @@ export async function getCollectionProductsPage(
const filters = parseFilterInputs(filterInputs);
const data = unwrapStorefrontResult(
await storefront.graphql(GET_COLLECTION_PRODUCTS_QUERY, {
await storefront.graphql<{
collection:
| (Collection & {
products: Connection<Product> & { filters: ProductFilterFacet[] };
})
| null;
}>(GET_COLLECTION_PRODUCTS_QUERY, {
variables: {
handle,
first,
@@ -364,11 +389,10 @@ interface SearchProductsOptions {
filterInputs?: string[];
}
// The `ProductFilter` input shape, taken from the query that consumes it so it
// tracks the schema rather than being restated here.
type ProductFilterInput = NonNullable<
StorefrontApi.VariablesOf<typeof GET_COLLECTION_PRODUCTS_QUERY>['filters']
>[number];
// The Storefront API's `ProductFilter` input. Facet `input` strings decode to
// one of these, so only the shape matters here.
// https://shopify.dev/docs/api/storefront/latest/input-objects/ProductFilter
type ProductFilterInput = Record<string, unknown>;
// Facet `input` values are opaque JSON strings produced by Shopify and handed
// straight back as filter inputs, so they are parsed, not constructed.
@@ -392,7 +416,12 @@ export async function searchProducts({
filterInputs = [],
}: SearchProductsOptions): Promise<SearchProductsResult> {
const data = unwrapStorefrontResult(
await storefront.graphql(SEARCH_PRODUCTS_QUERY, {
await storefront.graphql<{
search: Connection<SearchNode<Product>> & {
totalCount: number;
productFilters: SearchFilter[];
};
}>(SEARCH_PRODUCTS_QUERY, {
variables: {
query,
first,
@@ -412,9 +441,7 @@ export async function searchProducts({
return {
products: search.edges
.map((edge) => edge.node)
.filter((node): node is Extract<typeof node, { __typename: 'Product' }> =>
node.__typename === 'Product'
),
.filter(isProductNode),
totalCount: search.totalCount ?? 0,
filters: search.productFilters ?? [],
hasNextPage: Boolean(search.pageInfo?.hasNextPage),
@@ -427,7 +454,9 @@ export async function searchSuggestions(
first = 3
): Promise<{ products: SearchSuggestion[]; totalCount: number }> {
const data = unwrapStorefrontResult(
await storefront.graphql(SEARCH_SUGGESTIONS_QUERY, {
await storefront.graphql<{
search: Connection<SearchNode<SearchSuggestion>> & { totalCount: number };
}>(SEARCH_SUGGESTIONS_QUERY, {
variables: { query, first },
}),
'SearchSuggestions'
@@ -438,9 +467,7 @@ export async function searchSuggestions(
return {
products: search.edges
.map((edge) => edge.node)
.filter((node): node is Extract<typeof node, { __typename: 'Product' }> =>
node.__typename === 'Product'
),
.filter(isProductNode),
totalCount: search.totalCount ?? 0,
};
}
+105 -56
View File
@@ -1,80 +1,129 @@
// Storefront API clients, built on `@shopify/hydrogen`.
// Storefront API client: plain `fetch` against the GraphQL endpoint, no SDK.
// https://shopify.dev/docs/api/storefront
//
// Call sites use the package's own API — `storefront.graphql(DOCUMENT, {
// variables })` — and pass the result through `unwrapStorefrontResult`, which
// applies this app's error policy: fail loudly. Hydrogen deliberately does not
// do that itself, because a 200 carrying partial data and GraphQL errors is a
// valid response that some callers want to render.
import {
createShopifyRequestContext,
createStorefrontClient,
type GraphQLFormattedError,
} from '@shopify/hydrogen';
// Call sites use `storefront.graphql<TData>(QUERY, { variables })` and pass the
// result through `unwrapStorefrontResult`, which applies this app's error
// policy: fail loudly. `graphql()` itself returns `{ data, errors }` like the
// API does, because a 200 can carry partial data alongside GraphQL errors.
import {
SHOPIFY_API_VERSION,
SHOPIFY_PUBLIC_ACCESS_TOKEN,
SHOPIFY_STORE_DOMAIN,
} from '@/services/shopify/config';
// No incoming request and no buyer context: this client is module-scoped and
// serves both the Hono API and the browser-side hooks, so it 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' },
});
export interface StorefrontGraphQLError {
message: string;
path?: ReadonlyArray<string | number>;
extensions?: Record<string, unknown>;
}
/**
* Workaround for a bug in this preview build of `@shopify/hydrogen`.
*
* The client tags every request with `X-Hydrogen-Version`, but the Storefront
* API does not list that header in its CORS `access-control-allow-headers`.
* Browsers therefore reject the preflight and `fetch` throws, which hydrogen
* reports as the generic "SFAPI request failed". It only bites against real
* stores — `mock.shop` answers `access-control-allow-headers: *`.
*
* Stripped in the browser only: server-side requests are not subject to CORS,
* so they keep sending the header. Remove this once the API allows it (or once
* these queries move server-side, which is the better long-term fix).
*/
const CORS_BLOCKED_HEADERS = ['X-Hydrogen-Version'];
export interface StorefrontResult<TData> {
data: TData | null;
errors?: StorefrontGraphQLError[];
}
const storefrontFetch = ((url, init) => {
const headers = new Headers(init?.headers);
/** Transport failures: non-2xx responses and bodies that aren't GraphQL JSON. */
export class StorefrontApiError extends Error {
constructor(
message: string,
readonly status?: number
) {
super(message);
this.name = 'StorefrontApiError';
}
}
if (typeof document !== 'undefined') {
for (const header of CORS_BLOCKED_HEADERS) headers.delete(header);
// Buyer context for `@inContext(country: $country, language: $language)`.
// The client is module-scoped and serves both the Hono API and the browser, so
// it carries no per-request state; queries that declare these variables get
// them filled in unless the caller passes its own.
const DEFAULT_CONTEXT = { country: 'US', language: 'EN' } as const;
const STORE_URL = /^https?:\/\//.test(SHOPIFY_STORE_DOMAIN)
? SHOPIFY_STORE_DOMAIN.replace(/\/+$/, '')
: `https://${SHOPIFY_STORE_DOMAIN}`;
export const STOREFRONT_API_URL = `${STORE_URL}/api/${SHOPIFY_API_VERSION}/graphql.json`;
function withContextVariables(
query: string,
variables: Record<string, unknown> = {}
): Record<string, unknown> {
const merged = { ...variables };
for (const [name, value] of Object.entries(DEFAULT_CONTEXT)) {
if (merged[name] === undefined && query.includes(`$${name}`)) {
merged[name] = value;
}
}
return merged;
}
async function graphql<TData>(
query: string,
options: { variables?: Record<string, unknown> } = {}
): Promise<StorefrontResult<TData>> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
// Public token only; mock.shop needs none. Never send a private token here.
if (SHOPIFY_PUBLIC_ACCESS_TOKEN) {
headers['X-Shopify-Storefront-Access-Token'] = SHOPIFY_PUBLIC_ACCESS_TOKEN;
}
return globalThis.fetch(url, { ...init, cache: 'no-store', headers });
}) as typeof globalThis.fetch;
const response = await fetch(STOREFRONT_API_URL, {
method: 'POST',
headers,
body: JSON.stringify({
query,
variables: withContextVariables(query, options.variables),
}),
// Carts and customer reads must never be served stale; catalogue caching
// happens in TanStack Query instead (see the `staleTime`s).
cache: 'no-store',
});
/**
* The one Storefront client. Uncached at the HTTP layer, because carts and
* customer reads must never serve a stale response — response caching for
* catalogue data happens in TanStack Query instead (see `staleTime`s).
*/
export const storefront = createStorefrontClient({
type: 'public',
requestContext,
config: {
storeDomain: SHOPIFY_STORE_DOMAIN,
apiVersion: SHOPIFY_API_VERSION,
publicStorefrontToken: SHOPIFY_PUBLIC_ACCESS_TOKEN,
fetch: storefrontFetch,
},
});
let body: unknown;
try {
body = await response.json();
} catch {
throw new StorefrontApiError(
`Storefront API returned a non-JSON response (${response.status}).`,
response.status
);
}
// The API answers GraphQL-level problems with 200 + `errors`; anything else
// that lacks a GraphQL body is a transport failure.
const result = body as Partial<StorefrontResult<TData>> | null;
if (!result || (!('data' in result) && !('errors' in result))) {
throw new StorefrontApiError(
`Storefront API request failed (${response.status}).`,
response.status
);
}
if (!response.ok && !result.errors?.length) {
throw new StorefrontApiError(
`Storefront API request failed (${response.status}).`,
response.status
);
}
return { data: result.data ?? null, errors: result.errors };
}
/** The one Storefront client. */
export const storefront = { graphql };
/**
* Returns the data from a `graphql()` result, throwing if Shopify reported any
* GraphQL errors. Transport failures — non-200, timeouts, unparseable bodies —
* have already thrown as `StorefrontApiError` by this point.
* GraphQL errors. Transport failures have already thrown `StorefrontApiError`.
*
* `operation` names the query in the log and the thrown message, so a failure
* points at the call site rather than just at "Shopify".
*/
export function unwrapStorefrontResult<TData>(
result: { data: TData | null; errors?: GraphQLFormattedError[] },
result: StorefrontResult<TData>,
operation: string
): TData {
if (result.errors?.length) {
+29 -14
View File
@@ -80,6 +80,15 @@ export interface AuthResult {
errors: CustomerUserError[];
}
// Payload shapes the customer mutations select. Shopify returns a null payload
// when a mutation could not run at all.
interface UserErrorsPayload {
customerUserErrors: CustomerUserError[] | null;
}
type TokenPayload = UserErrorsPayload & {
customerAccessToken: AccessToken | null;
};
const firstMessage = (errors: CustomerUserError[]) =>
errors[0]?.message ?? 'Something went wrong. Please try again.';
@@ -100,9 +109,10 @@ export async function createCustomer(input: {
acceptsMarketing?: boolean;
}): Promise<{ errors: CustomerUserError[] }> {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_CREATE_MUTATION, {
variables: { input },
}),
await storefront.graphql<{ customerCreate: UserErrorsPayload | null }>(
CUSTOMER_CREATE_MUTATION,
{ variables: { input } }
),
'CustomerCreate'
);
@@ -117,9 +127,10 @@ export async function login(
password: string
): Promise<AuthResult> {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION, {
variables: { input: { email, password } },
}),
await storefront.graphql<{ customerAccessTokenCreate: TokenPayload | null }>(
CUSTOMER_ACCESS_TOKEN_CREATE_MUTATION,
{ variables: { input: { email, password } } }
),
'CustomerAccessTokenCreate'
);
@@ -161,9 +172,10 @@ export async function resetPassword(
password: string
): Promise<AuthResult> {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_RESET_MUTATION, {
variables: { id, input: { resetToken, password } },
}),
await storefront.graphql<{ customerReset: TokenPayload | null }>(
CUSTOMER_RESET_MUTATION,
{ variables: { id, input: { resetToken, password } } }
),
'CustomerReset'
);
@@ -182,9 +194,10 @@ export async function activateAccount(
password: string
): Promise<AuthResult> {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_ACTIVATE_MUTATION, {
variables: { id, input: { activationToken, password } },
}),
await storefront.graphql<{ customerActivate: TokenPayload | null }>(
CUSTOMER_ACTIVATE_MUTATION,
{ variables: { id, input: { activationToken, password } } }
),
'CustomerActivate'
);
@@ -203,7 +216,7 @@ export async function getCustomer(
): Promise<Customer | null> {
try {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_QUERY, {
await storefront.graphql<{ customer: Customer | null }>(CUSTOMER_QUERY, {
variables: { customerAccessToken: accessToken, orderCount },
}),
'GetCustomer'
@@ -227,7 +240,9 @@ export async function updateCustomer(
}
): Promise<{ customer: Customer | null; errors: CustomerUserError[] }> {
const data = unwrapStorefrontResult(
await storefront.graphql(CUSTOMER_UPDATE_MUTATION, {
await storefront.graphql<{
customerUpdate: (UserErrorsPayload & { customer: Customer | null }) | null;
}>(CUSTOMER_UPDATE_MUTATION, {
variables: { customerAccessToken: accessToken, customer },
}),
'CustomerUpdate'