Template
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:
co-authored by
Claude Opus 5.5
parent
d33a6894f3
commit
6fcfe59d0d
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user