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
@@ -6,6 +6,4 @@ dist
|
||||
src/routeTree.gen.ts
|
||||
*.tsbuildinfo
|
||||
|
||||
# Temp tsconfig written by `hydrogen gql check`; normally cleaned up on exit
|
||||
.hydrogen-gql-*.json
|
||||
.tanstack
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# Vite Shopify Storefront
|
||||
|
||||
A server-rendered Shopify storefront built with Vite, TanStack Start (TanStack
|
||||
Router + TanStack Query), Hono and Tailwind CSS v4. `@shopify/hydrogen` is used
|
||||
only as the Storefront API client and for typed `gql()` documents. It's a port
|
||||
of `nextjs-templates/shopify-storefront` without the AI store assistant.
|
||||
Router + TanStack Query), Hono and Tailwind CSS v4, talking to Shopify's
|
||||
[Storefront API](https://shopify.dev/docs/api/storefront) directly over
|
||||
GraphQL — no Shopify SDK. It's a port of `nextjs-templates/shopify-storefront`
|
||||
without the AI store assistant.
|
||||
|
||||
Runs in the nodesandbox runtime: `npm install` → `npm run dev` → port 3000.
|
||||
|
||||
@@ -20,7 +21,7 @@ yarn dev # http://localhost:3000
|
||||
| ---------------- | --------------------------------------------------------- |
|
||||
| `yarn dev` | Vite dev server with SSR and the Hono `/api` routes |
|
||||
| `yarn build` | Production build to `dist/` |
|
||||
| `yarn typecheck` | `tsc` plus `hydrogen gql check` for the GraphQL documents |
|
||||
| `yarn typecheck` | `tsc` |
|
||||
|
||||
## nodesandbox compatibility
|
||||
|
||||
@@ -50,8 +51,8 @@ src/
|
||||
account.ts login, logout, register, recover, reset, activate, me
|
||||
account.functions.ts server functions for account reads in loaders
|
||||
hooks/ TanStack Query options + hooks over the Storefront API
|
||||
services/shopify Storefront client, catalogue and customer functions
|
||||
graphql/ Typed `gql()` documents
|
||||
services/shopify Storefront API client (plain fetch), catalogue and customer functions
|
||||
graphql/ Storefront API queries and mutations (`gql()` joins fragments)
|
||||
components/ Storefront UI (header, cart drawer, product detail, ...)
|
||||
```
|
||||
|
||||
|
||||
+1
-3
@@ -6,14 +6,13 @@
|
||||
"scripts": {
|
||||
"dev": "vite dev --host 0.0.0.0 --port 3000",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit && hydrogen gql check"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"packageManager": "yarn@4.18.0+sha512.fcb8716fe7cd0eece141ffc18b92193a9df9204c1ba83189c288835223fc0bbe64af473bab0d5e9927a7daeb5caf2bb07eb2787cc9338ca040ea125f2a1f2f7e",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.3.0",
|
||||
"@fontsource-variable/geist-mono": "^5.3.0",
|
||||
"@remixicon/react": "^4.9.0",
|
||||
"@shopify/hydrogen": "0.0.0-preview-116d5d7-20260730141607",
|
||||
"@tanstack/query-core": "^5.104.1",
|
||||
"@tanstack/react-query": "^5.104.1",
|
||||
"@tanstack/react-router": "^1.170.41",
|
||||
@@ -23,7 +22,6 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"framer-motion": "^12.42.2",
|
||||
"graphql": "^17.0.2",
|
||||
"hono": "^4.13.12",
|
||||
"lucide-react": "^0.562.0",
|
||||
"radix-ui": "^1.6.7",
|
||||
|
||||
@@ -13,8 +13,8 @@ interface ProductsProps {
|
||||
}
|
||||
|
||||
const Products: React.FC<ProductsProps> = ({
|
||||
title = 'Shopify Hydrogen Storefront',
|
||||
subtitle = 'A Shopify storefront built with Vite, TanStack and Hydrogen.',
|
||||
title = 'Shopify Storefront',
|
||||
subtitle = 'A Shopify storefront built with Vite, TanStack and the Storefront API.',
|
||||
limit = 12,
|
||||
showLoadMore = true,
|
||||
}) => {
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
/** Chrome shared by every route — rendered once in the root layout. */
|
||||
export const site = {
|
||||
storeName: 'Shop',
|
||||
description: 'A Shopify storefront built with Vite, TanStack and Hydrogen.',
|
||||
description:
|
||||
'A Shopify storefront built with Vite, TanStack and the Storefront API.',
|
||||
// Absolute origin for canonical and Open Graph URLs (set in vite.config.ts).
|
||||
url: import.meta.env.VITE_SITE_URL ?? 'http://localhost:3000',
|
||||
logoUrl: '',
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { gql } from '@shopify/hydrogen';
|
||||
import { gql } from '@/graphql/gql';
|
||||
|
||||
// Cart Fragment for consistent cart data
|
||||
const CartFragment = gql(`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { gql } from '@shopify/hydrogen';
|
||||
import { gql } from '@/graphql/gql';
|
||||
import { ProductFragment } from '@/graphql/products';
|
||||
|
||||
// Get all collections
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Customer account operations (classic Storefront customer accounts).
|
||||
// https://shopify.dev/docs/api/storefront/latest/objects/Customer
|
||||
import { gql } from '@shopify/hydrogen';
|
||||
import { gql } from '@/graphql/gql';
|
||||
|
||||
const CustomerFragment = gql(`
|
||||
fragment CustomerFragment on Customer {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Builds a GraphQL document string: the operation or fragment source, followed
|
||||
* by any fragments it spreads. Fragments that themselves include fragments are
|
||||
* flattened and de-duplicated, so each definition appears once — the
|
||||
* Storefront API rejects a document that defines the same fragment twice.
|
||||
*/
|
||||
export function gql(source: string, fragments: readonly string[] = []): string {
|
||||
const definitions: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const add = (text: string) => {
|
||||
// A composed fragment is "its own source + its fragments", joined by blank
|
||||
// lines; split it back into definitions so shared ones de-duplicate.
|
||||
for (const part of text.split(/\n(?=\s*fragment\s)/)) {
|
||||
const definition = part.trim();
|
||||
if (definition && !seen.has(definition)) {
|
||||
seen.add(definition);
|
||||
definitions.push(definition);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
add(source);
|
||||
for (const fragment of fragments) add(fragment);
|
||||
|
||||
return definitions.join('\n\n');
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { gql } from '@shopify/hydrogen';
|
||||
import { gql } from '@/graphql/gql';
|
||||
|
||||
// Shop policies are exposed on the `shop` object of the Storefront API.
|
||||
// There is no lookup-by-handle field, so we fetch all of them and match.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { gql } from '@shopify/hydrogen';
|
||||
import { gql } from '@/graphql/gql';
|
||||
|
||||
// Product Fragment for consistent product data
|
||||
export const ProductFragment = gql(`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { gql } from '@shopify/hydrogen';
|
||||
import { gql } from '@/graphql/gql';
|
||||
import { ProductFragment } from '@/graphql/products';
|
||||
|
||||
// Storefront search over products. `productFilters` accepts the raw `input`
|
||||
|
||||
@@ -89,6 +89,12 @@ export interface Cart {
|
||||
};
|
||||
}
|
||||
|
||||
// Every cart mutation selects this payload; both it and its cart are nullable.
|
||||
type CartPayload = {
|
||||
cart: Cart | null;
|
||||
userErrors: Array<{ field?: string[] | null; message: string }>;
|
||||
} | null;
|
||||
|
||||
// ─── Shopify API functions ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -118,9 +124,12 @@ function unwrapCartPayload<T>(
|
||||
|
||||
async function createCartApi(lines: CartLineInput[] = []): Promise<Cart> {
|
||||
const data = unwrapStorefrontResult(
|
||||
await storefront.graphql(CREATE_CART_MUTATION, {
|
||||
variables: { lines: lines.length > 0 ? lines : null },
|
||||
}),
|
||||
await storefront.graphql<{ cartCreate: CartPayload }>(
|
||||
CREATE_CART_MUTATION,
|
||||
{
|
||||
variables: { lines: lines.length > 0 ? lines : null },
|
||||
}
|
||||
),
|
||||
'CreateCart'
|
||||
);
|
||||
|
||||
@@ -132,9 +141,12 @@ async function addCartLinesApi(
|
||||
lines: CartLineInput[]
|
||||
): Promise<Cart> {
|
||||
const data = unwrapStorefrontResult(
|
||||
await storefront.graphql(ADD_CART_LINES_MUTATION, {
|
||||
variables: { cartId, lines },
|
||||
}),
|
||||
await storefront.graphql<{ cartLinesAdd: CartPayload }>(
|
||||
ADD_CART_LINES_MUTATION,
|
||||
{
|
||||
variables: { cartId, lines },
|
||||
}
|
||||
),
|
||||
'AddCartLines'
|
||||
);
|
||||
|
||||
@@ -146,9 +158,12 @@ async function updateCartLinesApi(
|
||||
lines: CartLineUpdateInput[]
|
||||
): Promise<Cart> {
|
||||
const data = unwrapStorefrontResult(
|
||||
await storefront.graphql(UPDATE_CART_LINES_MUTATION, {
|
||||
variables: { cartId, lines },
|
||||
}),
|
||||
await storefront.graphql<{ cartLinesUpdate: CartPayload }>(
|
||||
UPDATE_CART_LINES_MUTATION,
|
||||
{
|
||||
variables: { cartId, lines },
|
||||
}
|
||||
),
|
||||
'UpdateCartLines'
|
||||
);
|
||||
|
||||
@@ -160,9 +175,12 @@ async function removeCartLinesApi(
|
||||
lineIds: string[]
|
||||
): Promise<Cart> {
|
||||
const data = unwrapStorefrontResult(
|
||||
await storefront.graphql(REMOVE_CART_LINES_MUTATION, {
|
||||
variables: { cartId, lineIds },
|
||||
}),
|
||||
await storefront.graphql<{ cartLinesRemove: CartPayload }>(
|
||||
REMOVE_CART_LINES_MUTATION,
|
||||
{
|
||||
variables: { cartId, lineIds },
|
||||
}
|
||||
),
|
||||
'RemoveCartLines'
|
||||
);
|
||||
|
||||
@@ -174,9 +192,12 @@ async function updateCartDiscountCodesApi(
|
||||
discountCodes: string[]
|
||||
): Promise<Cart> {
|
||||
const data = unwrapStorefrontResult(
|
||||
await storefront.graphql(UPDATE_CART_DISCOUNT_CODES_MUTATION, {
|
||||
variables: { cartId, discountCodes },
|
||||
}),
|
||||
await storefront.graphql<{ cartDiscountCodesUpdate: CartPayload }>(
|
||||
UPDATE_CART_DISCOUNT_CODES_MUTATION,
|
||||
{
|
||||
variables: { cartId, discountCodes },
|
||||
}
|
||||
),
|
||||
'UpdateCartDiscountCodes'
|
||||
);
|
||||
|
||||
@@ -185,7 +206,9 @@ async function updateCartDiscountCodesApi(
|
||||
|
||||
async function getCartApi(cartId: string): Promise<Cart | null> {
|
||||
const data = unwrapStorefrontResult(
|
||||
await storefront.graphql(GET_CART_QUERY, { variables: { cartId } }),
|
||||
await storefront.graphql<{ cart: Cart | null }>(GET_CART_QUERY, {
|
||||
variables: { cartId },
|
||||
}),
|
||||
'GetCart'
|
||||
);
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ export const POLICY_HANDLES = [
|
||||
export async function getShopPolicies(): Promise<ShopPolicy[]> {
|
||||
try {
|
||||
const data = unwrapStorefrontResult(
|
||||
await storefront.graphql(GET_SHOP_POLICIES_QUERY),
|
||||
await storefront.graphql<{
|
||||
shop: Record<string, ShopPolicy | null> | null;
|
||||
}>(GET_SHOP_POLICIES_QUERY),
|
||||
'GetShopPolicies'
|
||||
);
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ export const Route = createFileRoute('/')({
|
||||
head: () => seo({ title: site.storeName, absolute: true, path: '/' }),
|
||||
component: () => (
|
||||
<Products
|
||||
title="Vite + Shopify Hydrogen"
|
||||
subtitle="A Shopify storefront built with Vite, TanStack and Hydrogen."
|
||||
title="Vite + Shopify Storefront"
|
||||
subtitle="A Shopify storefront built with Vite, TanStack and the Storefront API."
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client", "node"],
|
||||
"plugins": [{ "name": "@shopify/hydrogen/ts-plugin" }],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
|
||||
@@ -5,31 +5,6 @@ __metadata:
|
||||
version: 10
|
||||
cacheKey: 10c0
|
||||
|
||||
"@0no-co/graphql.web@npm:^1.0.5, @0no-co/graphql.web@npm:^1.3.1":
|
||||
version: 1.3.4
|
||||
resolution: "@0no-co/graphql.web@npm:1.3.4"
|
||||
peerDependencies:
|
||||
graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0
|
||||
peerDependenciesMeta:
|
||||
graphql:
|
||||
optional: true
|
||||
checksum: 10c0/58c61e16664121bd0974609890bf68135469fa1af23c70c3966cf50f0215efac7062a4ff2ded60faaf7575e420b73c0800467e46fc0440942d48490d2f6c13bd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@0no-co/graphqlsp@npm:^1.12.13":
|
||||
version: 1.17.5
|
||||
resolution: "@0no-co/graphqlsp@npm:1.17.5"
|
||||
dependencies:
|
||||
"@gql.tada/internal": "npm:^1.2.1"
|
||||
graphql: "npm:^15.5.0 || ^16.0.0 || ^17.0.0"
|
||||
peerDependencies:
|
||||
graphql: ^15.5.0 || ^16.0.0 || ^17.0.0
|
||||
typescript: ^5.0.0 || ^6.0.0
|
||||
checksum: 10c0/6b40d666ce9b237d0aebb13d17770c009e4416e8955c6636acaef49174b1bde5d612c7a6d7656a3053518cbbfbb85f9d039df78b595233c365878d2091987ed2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/code-frame@npm:7.29.7, @babel/code-frame@npm:^7.29.7":
|
||||
version: 7.29.7
|
||||
resolution: "@babel/code-frame@npm:7.29.7"
|
||||
@@ -313,52 +288,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@gql.tada/cli-utils@npm:1.7.3":
|
||||
version: 1.7.3
|
||||
resolution: "@gql.tada/cli-utils@npm:1.7.3"
|
||||
dependencies:
|
||||
"@0no-co/graphqlsp": "npm:^1.12.13"
|
||||
"@gql.tada/internal": "npm:1.0.9"
|
||||
graphql: "npm:^15.5.0 || ^16.0.0 || ^17.0.0"
|
||||
peerDependencies:
|
||||
"@0no-co/graphqlsp": ^1.12.13
|
||||
"@gql.tada/svelte-support": 1.0.2
|
||||
"@gql.tada/vue-support": 1.0.2
|
||||
graphql: ^15.5.0 || ^16.0.0 || ^17.0.0
|
||||
typescript: ^5.0.0 || ^6.0.0
|
||||
peerDependenciesMeta:
|
||||
"@gql.tada/svelte-support":
|
||||
optional: true
|
||||
"@gql.tada/vue-support":
|
||||
optional: true
|
||||
checksum: 10c0/55405687375cbda9a9e963fc627939a55c64e81816c8d6433add3564f2fc2c0600ec60c98e01de77fa21511713daf9e84b644362d9ce571f0ea5c43851c68b6f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@gql.tada/internal@npm:1.0.9":
|
||||
version: 1.0.9
|
||||
resolution: "@gql.tada/internal@npm:1.0.9"
|
||||
dependencies:
|
||||
"@0no-co/graphql.web": "npm:^1.0.5"
|
||||
peerDependencies:
|
||||
graphql: ^15.5.0 || ^16.0.0 || ^17.0.0
|
||||
typescript: ^5.0.0 || ^6.0.0
|
||||
checksum: 10c0/5599ab58bc5859bf9d38f13e4ca0aae564cf7e94fbfa9afdb9932d4719fb945cb19da917fe23ed0cf115550ce07d31bcac89c229385fe7077754b54c2bed16e9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@gql.tada/internal@npm:^1.2.1":
|
||||
version: 1.2.2
|
||||
resolution: "@gql.tada/internal@npm:1.2.2"
|
||||
dependencies:
|
||||
"@0no-co/graphql.web": "npm:^1.3.1"
|
||||
peerDependencies:
|
||||
graphql: ^15.5.0 || ^16.0.0 || ^17.0.0
|
||||
typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
checksum: 10c0/2861061a64fa88dbd8bac84cff87bc33a30e9ed971cbee9f8d9a52ec27a17ffc64c389fb6a75f2231458524556829b81f4744b55ccb433e46d28e1066a5d4655
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@isaacs/fs-minipass@npm:^4.0.0":
|
||||
version: 4.0.1
|
||||
resolution: "@isaacs/fs-minipass@npm:4.0.1"
|
||||
@@ -2010,25 +1939,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@shopify/hydrogen@npm:0.0.0-preview-116d5d7-20260730141607":
|
||||
version: 0.0.0-preview-116d5d7-20260730141607
|
||||
resolution: "@shopify/hydrogen@npm:0.0.0-preview-116d5d7-20260730141607"
|
||||
dependencies:
|
||||
gql.tada: "npm:1.9.2"
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
vue: ^3.5.0
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
vue:
|
||||
optional: true
|
||||
bin:
|
||||
hydrogen: bin/hydrogen.mjs
|
||||
checksum: 10c0/b12e230d174a910d8abb7ab18ecb99357dce82d2125054b30af6dcbecf280c6fea40c8ca5fd67d8931e1079fd73e587e3ea1484f50c294f4a79ad714c9664ecd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tailwindcss/node@npm:4.3.1":
|
||||
version: 4.3.1
|
||||
resolution: "@tailwindcss/node@npm:4.3.1"
|
||||
@@ -2876,23 +2786,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"gql.tada@npm:1.9.2":
|
||||
version: 1.9.2
|
||||
resolution: "gql.tada@npm:1.9.2"
|
||||
dependencies:
|
||||
"@0no-co/graphql.web": "npm:^1.0.5"
|
||||
"@0no-co/graphqlsp": "npm:^1.12.13"
|
||||
"@gql.tada/cli-utils": "npm:1.7.3"
|
||||
"@gql.tada/internal": "npm:1.0.9"
|
||||
peerDependencies:
|
||||
typescript: ^5.0.0 || ^6.0.0
|
||||
bin:
|
||||
gql-tada: bin/cli.js
|
||||
gql.tada: bin/cli.js
|
||||
checksum: 10c0/e6995d5ddd41a0049f7e2494758d605567027c57b270e59c48355d7fbd0ca95db77db2385c14d2add1198ef59b91c1b5a7f9b70d9eac8668c2be534e55a56517
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6":
|
||||
version: 4.2.11
|
||||
resolution: "graceful-fs@npm:4.2.11"
|
||||
@@ -2900,13 +2793,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"graphql@npm:^15.5.0 || ^16.0.0 || ^17.0.0, graphql@npm:^17.0.2":
|
||||
version: 17.0.2
|
||||
resolution: "graphql@npm:17.0.2"
|
||||
checksum: 10c0/766546216b891439ed09fd8584963df2013e26f2f79f096036eaaa2788c38964e049c8a142d68e3afdabdd8bbe70042639e83f0f4b82edbc7c6850421f072bb6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"h3-v2@npm:h3@2.0.1-rc.20":
|
||||
version: 2.0.1-rc.20
|
||||
resolution: "h3@npm:2.0.1-rc.20"
|
||||
@@ -3923,7 +3809,6 @@ __metadata:
|
||||
"@fontsource-variable/geist": "npm:^5.3.0"
|
||||
"@fontsource-variable/geist-mono": "npm:^5.3.0"
|
||||
"@remixicon/react": "npm:^4.9.0"
|
||||
"@shopify/hydrogen": "npm:0.0.0-preview-116d5d7-20260730141607"
|
||||
"@tailwindcss/vite": "npm:4.3.1"
|
||||
"@tanstack/query-core": "npm:^5.104.1"
|
||||
"@tanstack/react-query": "npm:^5.104.1"
|
||||
@@ -3938,7 +3823,6 @@ __metadata:
|
||||
clsx: "npm:^2.1.1"
|
||||
cmdk: "npm:^1.1.1"
|
||||
framer-motion: "npm:^12.42.2"
|
||||
graphql: "npm:^17.0.2"
|
||||
hono: "npm:^4.13.12"
|
||||
lucide-react: "npm:^0.562.0"
|
||||
radix-ui: "npm:^1.6.7"
|
||||
|
||||
Reference in New Issue
Block a user