From 814004397c1d17ef0a53a425ed28a42cf67765cf Mon Sep 17 00:00:00 2001 From: Bret Little Date: Mon, 30 Oct 2023 09:55:02 -0400 Subject: [PATCH] Add a Customer Account API Abstraction (#1430) --- .changeset/chatty-parrots-care.md | 5 + examples/customer-api/app/routes/_index.tsx | 37 +- .../customer-api/app/utils/customer.server.ts | 367 --------------- examples/customer-api/remix.config.js | 1 + examples/customer-api/remix.env.d.ts | 2 +- examples/customer-api/server.ts | 12 +- .../docs/generated/generated_docs_data.json | 416 +++++++++++++---- packages/hydrogen/src/customer/BadRequest.ts | 11 + .../src/customer/auth.helpers.test.ts | 323 +++++++++++++ .../hydrogen/src/customer/auth.helpers.ts | 264 +++++++++++ .../hydrogen/src/customer/customer.doc.ts | 48 ++ .../src/customer/customer.example.jsx | 75 +++ .../src/customer/customer.example.tsx | 86 ++++ .../hydrogen/src/customer/customer.test.ts | 427 ++++++++++++++++++ packages/hydrogen/src/customer/customer.ts | 366 +++++++++++++++ packages/hydrogen/src/index.ts | 2 + .../pagination/getPaginationVariables.doc.ts | 5 +- packages/hydrogen/src/storefront.ts | 86 +--- packages/hydrogen/src/utils/graphql.ts | 70 +++ 19 files changed, 2068 insertions(+), 535 deletions(-) create mode 100644 .changeset/chatty-parrots-care.md delete mode 100644 examples/customer-api/app/utils/customer.server.ts create mode 100644 packages/hydrogen/src/customer/BadRequest.ts create mode 100644 packages/hydrogen/src/customer/auth.helpers.test.ts create mode 100644 packages/hydrogen/src/customer/auth.helpers.ts create mode 100644 packages/hydrogen/src/customer/customer.doc.ts create mode 100644 packages/hydrogen/src/customer/customer.example.jsx create mode 100644 packages/hydrogen/src/customer/customer.example.tsx create mode 100644 packages/hydrogen/src/customer/customer.test.ts create mode 100644 packages/hydrogen/src/customer/customer.ts create mode 100644 packages/hydrogen/src/utils/graphql.ts diff --git a/.changeset/chatty-parrots-care.md b/.changeset/chatty-parrots-care.md new file mode 100644 index 0000000000..796d3df54c --- /dev/null +++ b/.changeset/chatty-parrots-care.md @@ -0,0 +1,5 @@ +--- +'@shopify/hydrogen': patch +--- + +Add a client for query the new Customer Account API diff --git a/examples/customer-api/app/routes/_index.tsx b/examples/customer-api/app/routes/_index.tsx index 0be2fbe792..4ad70a9910 100644 --- a/examples/customer-api/app/routes/_index.tsx +++ b/examples/customer-api/app/routes/_index.tsx @@ -3,8 +3,10 @@ import {type LoaderFunctionArgs, json} from '@shopify/remix-oxygen'; export async function loader({context}: LoaderFunctionArgs) { if (await context.customer.isLoggedIn()) { - const user = await context.customer.query(` - { + const {customer} = await context.customer.query<{ + customer: {firstName: string; lastName: string}; + }>(`#graphql + query getCustomer { customer { firstName lastName @@ -12,11 +14,26 @@ export async function loader({context}: LoaderFunctionArgs) { } `); - return json({ - user, - }); + return json( + { + customer, + }, + { + headers: { + 'Set-Cookie': await context.session.commit(), + }, + }, + ); } - return json({user: null}); + + return json( + {customer: null}, + { + headers: { + 'Set-Cookie': await context.session.commit(), + }, + }, + ); } export function ErrorBoundary() { @@ -36,15 +53,15 @@ export function ErrorBoundary() { } export default function () { - const {user} = useLoaderData() as any; + const {customer} = useLoaderData(); return (
- {user ? ( + {customer ? ( <>
- Welcome {user.customer.firstName} {user.customer.lastName} + Welcome {customer.firstName} {customer.lastName}
@@ -54,7 +71,7 @@ export default function () {
) : null} - {!user ? ( + {!customer ? (
diff --git a/examples/customer-api/app/utils/customer.server.ts b/examples/customer-api/app/utils/customer.server.ts deleted file mode 100644 index ea63faf2c2..0000000000 --- a/examples/customer-api/app/utils/customer.server.ts +++ /dev/null @@ -1,367 +0,0 @@ -import {redirect} from '@shopify/remix-oxygen'; -import {HydrogenSession} from 'server'; - -const userAgent = - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.93 Safari/537.36'; - -export type CustomerClient = { - logout: () => Promise; - authorize: (redirectPath?: string) => Promise; - isLoggedIn: () => Promise; - login: () => Promise; - query: ( - query: string, - variables?: any, - ) => Promise<{data: unknown; status: number; ok: boolean}>; -}; - -export function createCustomerClient({ - session, - customerAccountId, - customerAccountUrl, - customerApiVersion = '2023-10', - request, -}: { - session: HydrogenSession; - customerAccountId: string; - customerAccountUrl: string; - customerApiVersion?: string; - request: Request; -}): CustomerClient { - const origin = new URL(request.url).origin.replace('http', 'https'); - return { - login: async () => { - const loginUrl = new URL(customerAccountUrl + '/auth/oauth/authorize'); - - loginUrl.searchParams.set('client_id', customerAccountId); - loginUrl.searchParams.set('scope', 'openid email'); - loginUrl.searchParams.append('response_type', 'code'); - loginUrl.searchParams.append('redirect_uri', origin + '/authorize'); - loginUrl.searchParams.set( - 'scope', - 'openid email https://api.customers.com/auth/customer.graphql', - ); - loginUrl.searchParams.append('state', ''); - loginUrl.searchParams.append('nonce', ''); - const verifier = await generateCodeVerifier(); - const challenge = await generateCodeChallenge(verifier); - - session.set('code-verifier', verifier); - - loginUrl.searchParams.append('code_challenge', challenge); - loginUrl.searchParams.append('code_challenge_method', 'S256'); - - return redirect(loginUrl.toString(), { - headers: { - 'Set-Cookie': await session.commit(), - }, - }); - }, - logout: async () => { - const idToken = session.get('id_token'); - - session.unset('code-verifier'); - session.unset('customer_authorization_code_token'); - session.unset('expires_at'); - session.unset('id_token'); - session.unset('refresh_token'); - session.unset('customer_access_token'); - - return redirect( - `${customerAccountUrl}/auth/logout?id_token_hint=${idToken}`, - { - status: 302, - - headers: { - 'Set-Cookie': await session.commit(), - }, - }, - ); - }, - isLoggedIn: async () => { - if (!session.get('customer_access_token')) return false; - - if (session.get('expires_at') < new Date().getTime()) { - try { - await refreshToken( - request, - session, - customerAccountId, - customerAccountUrl, - origin, - ); - - return true; - } catch (error) { - if (error && (error as Response).status !== 401) { - throw error; - } - } - } else { - return true; - } - - session.unset('code-verifier'); - session.unset('customer_authorization_code_token'); - session.unset('expires_at'); - session.unset('id_token'); - session.unset('refresh_token'); - session.unset('customer_access_token'); - - return false; - }, - query: async (query: string, variables?: any) => { - return await fetch( - `${customerAccountUrl}/account/customer/api/${customerApiVersion}/graphql`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'User-Agent': userAgent, - Origin: origin, - Authorization: session.get('customer_access_token'), - }, - body: JSON.stringify({ - operationName: 'SomeQuery', - query, - variables: variables || {}, - }), - }, - ).then(async (response) => { - if (!response.ok) { - throw new Error( - `${response.status} (RequestID ${response.headers.get( - 'x-request-id', - )}): ${await response.text()}`, - ); - } - return ((await response.json()) as any).data; - }); - }, - authorize: async (redirectPath = '/') => { - const code = new URL(request.url).searchParams.get('code'); - - if (!code) throw new BadRequest('Code search param is required'); - - const clientId = customerAccountId; - const body = new URLSearchParams(); - - body.append('grant_type', 'authorization_code'); - body.append('client_id', clientId); - body.append( - 'redirect_uri', - new URL(request.url).origin.replace('http', 'https') + '/authorize', - ); - body.append('code', code); - - // Public Client - const codeVerifier = session.get('code-verifier'); - body.append('code_verifier', codeVerifier); - - const headers = { - 'content-type': 'application/x-www-form-urlencoded', - 'User-Agent': userAgent, - Origin: new URL(request.url).origin.replace('http', 'https'), - }; - - const response = await fetch(`${customerAccountUrl}/auth/oauth/token`, { - method: 'POST', - headers, - body, - }); - - interface AccessTokenResponse { - access_token: string; - expires_in: number; - id_token: string; - refresh_token: string; - } - - if (!response.ok) { - throw new Response(await response.text(), { - status: response.status, - headers: { - 'Content-Type': 'text/html; charset=utf-8', - }, - }); - } - - const {access_token, expires_in, id_token, refresh_token} = - await response.json(); - - session.set('customer_authorization_code_token', access_token); - session.set( - 'expires_at', - new Date(new Date().getTime() + (expires_in - 120) * 1000).getTime(), - ); - session.set('id_token', id_token); - session.set('refresh_token', refresh_token); - - const customerAccessToken = await exchangeAccessToken( - request, - session, - customerAccountId, - customerAccountUrl, - origin, - ); - - session.set('customer_access_token', customerAccessToken); - - return redirect(redirectPath, { - headers: { - 'Set-Cookie': await session.commit(), - }, - }); - }, - }; -} - -async function exchangeAccessToken( - request: Request, - session: HydrogenSession, - customerAccountId: string, - customerAccountUrl: string, - origin: string, -) { - const clientId = customerAccountId; - const customerApiClientId = '30243aa5-17c1-465a-8493-944bcc4e88aa'; - const accessToken = session.get('customer_authorization_code_token'); - const body = new URLSearchParams(); - - body.append('grant_type', 'urn:ietf:params:oauth:grant-type:token-exchange'); - body.append('client_id', clientId); - body.append('audience', customerApiClientId); - body.append('subject_token', accessToken); - body.append( - 'subject_token_type', - 'urn:ietf:params:oauth:token-type:access_token', - ); - body.append('scopes', 'https://api.customers.com/auth/customer.graphql'); - - const headers = { - 'content-type': 'application/x-www-form-urlencoded', - 'User-Agent': userAgent, - Origin: origin, - }; - - const response = await fetch(`${customerAccountUrl}/auth/oauth/token`, { - method: 'POST', - headers, - body, - }); - - interface AccessTokenResponse { - access_token: string; - expires_in: number; - error?: string; - error_description?: string; - } - - const data = await response.json(); - if (data.error) { - throw new BadRequest(data.error_description); - } - return data.access_token; -} -async function refreshToken( - request: Request, - session: HydrogenSession, - customerAccountId: string, - customerAccountUrl: string, - origin: string, -) { - const newBody = new URLSearchParams(); - - newBody.append('grant_type', 'refresh_token'); - newBody.append('refresh_token', session.get('refresh_token')); - newBody.append('client_id', customerAccountId); - - const headers = { - 'content-type': 'application/x-www-form-urlencoded', - 'User-Agent': userAgent, - Origin: origin, - }; - - const response = await fetch(`${customerAccountUrl}/auth/oauth/token`, { - method: 'POST', - headers, - body: newBody, - }); - - interface AccessTokenResponse { - access_token: string; - expires_in: number; - id_token: string; - refresh_token: string; - } - - if (!response.ok) { - const text = await response.text(); - throw new Response(text, { - status: response.status, - headers: { - 'Content-Type': 'text/html; charset=utf-8', - }, - }); - } - - const {access_token, expires_in, id_token, refresh_token} = - await response.json(); - - session.set('customer_authorization_code_token', access_token); - // Store the date in future the token expires, separated by two minutes - session.set( - 'expires_at', - new Date(new Date().getTime() + (expires_in - 120) * 1000).getTime(), - ); - session.set('id_token', id_token); - session.set('refresh_token', refresh_token); - - const customerAccessToken = await exchangeAccessToken( - request, - session, - customerAccountId, - customerAccountUrl, - origin, - ); - - session.set('customer_access_token', customerAccessToken); -} - -async function generateCodeVerifier() { - const rando = generateRandomCode(); - return base64UrlEncode(rando); -} - -async function generateCodeChallenge(codeVerifier: string) { - const digestOp = await crypto.subtle.digest( - {name: 'SHA-256'}, - new TextEncoder().encode(codeVerifier), - ); - const hash = convertBufferToString(digestOp); - return base64UrlEncode(hash); -} - -function generateRandomCode() { - const array = new Uint8Array(32); - crypto.getRandomValues(array); - return String.fromCharCode.apply(null, Array.from(array)); -} - -function base64UrlEncode(str: string) { - const base64 = btoa(str); - // This is to ensure that the encoding does not have +, /, or = characters in it. - return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); -} - -function convertBufferToString(hash: ArrayBuffer) { - const uintArray = new Uint8Array(hash); - const numberArray = Array.from(uintArray); - return String.fromCharCode(...numberArray); -} - -class BadRequest extends Response { - constructor(message?: string) { - super(`Bad request: ${message}`, {status: 400}); - } -} diff --git a/examples/customer-api/remix.config.js b/examples/customer-api/remix.config.js index 46e4d95e1f..d00cc56cf4 100644 --- a/examples/customer-api/remix.config.js +++ b/examples/customer-api/remix.config.js @@ -15,5 +15,6 @@ module.exports = { serverDependenciesToBundle: 'all', serverModuleFormat: 'esm', serverPlatform: 'neutral', + serverMinify: process.env.NODE_ENV === 'production', }; diff --git a/examples/customer-api/remix.env.d.ts b/examples/customer-api/remix.env.d.ts index fdba46fde0..834652d51f 100644 --- a/examples/customer-api/remix.env.d.ts +++ b/examples/customer-api/remix.env.d.ts @@ -4,7 +4,7 @@ import type {Storefront} from '@shopify/hydrogen'; import type {HydrogenSession} from './server'; -import type {CustomerClient} from '~/utils/customer.server'; +import type {CustomerClient} from '@shopify/hydrogen'; declare global { /** diff --git a/examples/customer-api/server.ts b/examples/customer-api/server.ts index e9223f586b..761d1287b8 100644 --- a/examples/customer-api/server.ts +++ b/examples/customer-api/server.ts @@ -1,6 +1,10 @@ // Virtual entry point for the app import * as remixBuild from '@remix-run/dev/server-build'; -import {createStorefrontClient, storefrontRedirect} from '@shopify/hydrogen'; +import { + createStorefrontClient, + storefrontRedirect, + createCustomerClient__unstable, +} from '@shopify/hydrogen'; import { createRequestHandler, getStorefrontHeaders, @@ -8,7 +12,6 @@ import { type SessionStorage, type Session, } from '@shopify/remix-oxygen'; -import {createCustomerClient} from '~/utils/customer.server'; /** * Export a fetch handler in module format. @@ -50,7 +53,8 @@ export default { /** * Create a customer client for the new customer API. */ - const customer = createCustomerClient({ + const customer = createCustomerClient__unstable({ + waitUntil, request, session, customerAccountId: env.PUBLIC_CUSTOMER_ACCOUNT_ID, @@ -78,8 +82,6 @@ export default { return storefrontRedirect({request, response, storefront}); } - response.headers.set('Set-Cookie', await session.commit()); - return response; } catch (error) { // eslint-disable-next-line no-console diff --git a/packages/hydrogen/docs/generated/generated_docs_data.json b/packages/hydrogen/docs/generated/generated_docs_data.json index 94b7eaf306..e461686d8f 100644 --- a/packages/hydrogen/docs/generated/generated_docs_data.json +++ b/packages/hydrogen/docs/generated/generated_docs_data.json @@ -38,7 +38,7 @@ }, { "title": "TypeScript", - "code": "import {json, type LoaderArgs} from '@shopify/remix-oxygen';\nimport {CacheCustom} from '@shopify/hydrogen';\n\nexport async function loader({context}: LoaderArgs) {\n const data = await context.storefront.query(\n `#grahpql\n {\n shop {\n name\n description\n }\n }`,\n {\n cache: CacheCustom({\n maxAge: 1000 * 60 * 60 * 24 * 365,\n staleWhileRevalidate: 1000 * 60 * 60 * 24 * 7,\n }),\n },\n );\n\n return json(data);\n}\n", + "code": "import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';\nimport {CacheCustom} from '@shopify/hydrogen';\n\nexport async function loader({context}: LoaderFunctionArgs) {\n const data = await context.storefront.query(\n `#grahpql\n {\n shop {\n name\n description\n }\n }`,\n {\n cache: CacheCustom({\n maxAge: 1000 * 60 * 60 * 24 * 365,\n staleWhileRevalidate: 1000 * 60 * 60 * 24 * 7,\n }),\n },\n );\n\n return json(data);\n}\n", "language": "ts" } ], @@ -162,7 +162,7 @@ }, { "title": "TypeScript", - "code": "import {json, type LoaderArgs} from '@shopify/remix-oxygen';\nimport {CacheLong} from '@shopify/hydrogen';\n\nexport async function loader({context}: LoaderArgs) {\n const data = await context.storefront.query(\n `#grahpql\n {\n shop {\n name\n description\n }\n }`,\n {\n cache: CacheLong(),\n },\n );\n\n return json(data);\n}\n", + "code": "import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';\nimport {CacheLong} from '@shopify/hydrogen';\n\nexport async function loader({context}: LoaderFunctionArgs) {\n const data = await context.storefront.query(\n `#grahpql\n {\n shop {\n name\n description\n }\n }`,\n {\n cache: CacheLong(),\n },\n );\n\n return json(data);\n}\n", "language": "ts" } ], @@ -287,7 +287,7 @@ }, { "title": "TypeScript", - "code": "import {json, type LoaderArgs} from '@shopify/remix-oxygen';\nimport {CacheNone} from '@shopify/hydrogen';\n\nexport async function loader({context}: LoaderArgs) {\n const data = await context.storefront.query(\n `#grahpql\n {\n shop {\n name\n description\n }\n }`,\n {\n cache: CacheNone(),\n },\n );\n\n return json(data);\n}\n", + "code": "import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';\nimport {CacheNone} from '@shopify/hydrogen';\n\nexport async function loader({context}: LoaderFunctionArgs) {\n const data = await context.storefront.query(\n `#grahpql\n {\n shop {\n name\n description\n }\n }`,\n {\n cache: CacheNone(),\n },\n );\n\n return json(data);\n}\n", "language": "ts" } ], @@ -372,7 +372,7 @@ }, { "title": "TypeScript", - "code": "import {json, type LoaderArgs} from '@shopify/remix-oxygen';\nimport {CacheShort} from '@shopify/hydrogen';\n\nexport async function loader({context}: LoaderArgs) {\n const data = await context.storefront.query(\n `#grahpql\n {\n shop {\n name\n description\n }\n }`,\n {\n cache: CacheShort(),\n },\n );\n\n return json(data);\n}\n", + "code": "import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';\nimport {CacheShort} from '@shopify/hydrogen';\n\nexport async function loader({context}: LoaderFunctionArgs) {\n const data = await context.storefront.query(\n `#grahpql\n {\n shop {\n name\n description\n }\n }`,\n {\n cache: CacheShort(),\n },\n );\n\n return json(data);\n}\n", "language": "ts" } ], @@ -607,7 +607,7 @@ }, { "title": "TypeScript", - "code": "import {type ActionArgs, json} from '@remix-run/server-runtime';\nimport {\n type CartQueryData,\n type HydrogenCart,\n CartForm,\n} from '@shopify/hydrogen';\nimport invariant from 'tiny-invariant';\n\nexport default function Cart() {\n return (\n <CartForm\n action={CartForm.ACTIONS.LinesUpdate}\n inputs={{\n lines: [\n {\n id: 'gid://shopify/CartLine/123456789',\n quantity: 3,\n },\n ],\n other: 'data',\n }}\n >\n <button>Quantity up</button>\n </CartForm>\n );\n}\n\nexport async function action({request, context}: ActionArgs) {\n const cart = context.cart as HydrogenCart;\n // cart is type HydrogenCart or HydrogenCartCustom\n // Declare cart type in remix.env.d.ts for interface AppLoadContext to avoid type casting\n // const {cart} = context;\n\n const formData = await request.formData();\n const {action, inputs} = CartForm.getFormInput(formData);\n\n let status = 200;\n let result: CartQueryData;\n\n if (action === CartForm.ACTIONS.LinesUpdate) {\n result = await cart.updateLines(inputs.lines);\n } else {\n invariant(false, `${action} cart action is not defined`);\n }\n\n const headers = cart.setCartId(result.cart.id);\n\n return json(result, {status, headers});\n}\n", + "code": "import {type ActionFunctionArgs, json} from '@remix-run/server-runtime';\nimport {\n type CartQueryData,\n type HydrogenCart,\n CartForm,\n} from '@shopify/hydrogen';\nimport invariant from 'tiny-invariant';\n\nexport default function Cart() {\n return (\n <CartForm\n action={CartForm.ACTIONS.LinesUpdate}\n inputs={{\n lines: [\n {\n id: 'gid://shopify/CartLine/123456789',\n quantity: 3,\n },\n ],\n other: 'data',\n }}\n >\n <button>Quantity up</button>\n </CartForm>\n );\n}\n\nexport async function action({request, context}: ActionFunctionArgs) {\n const cart = context.cart as HydrogenCart;\n // cart is type HydrogenCart or HydrogenCartCustom\n // Declare cart type in remix.env.d.ts for interface AppLoadContext to avoid type casting\n // const {cart} = context;\n\n const formData = await request.formData();\n const {action, inputs} = CartForm.getFormInput(formData);\n\n let status = 200;\n let result: CartQueryData;\n\n if (action === CartForm.ACTIONS.LinesUpdate) {\n result = await cart.updateLines(inputs.lines);\n } else {\n invariant(false, `${action} cart action is not defined`);\n }\n\n const headers = cart.setCartId(result.cart.id);\n\n return json(result, {status, headers});\n}\n", "language": "ts" } ], @@ -934,7 +934,7 @@ "filePath": "/cart/CartForm.tsx", "syntaxKind": "TypeAliasDeclaration", "name": "CartMetafieldDeleteProps", - "value": "{\n action: 'MetafieldsDelete';\n inputs?: {\n key: Scalars['String'];\n } & OtherFormData;\n}", + "value": "{\n action: 'MetafieldsDelete';\n inputs?: {\n key: Scalars['String']['input'];\n } & OtherFormData;\n}", "description": "", "members": [ { @@ -948,7 +948,7 @@ "filePath": "/cart/CartForm.tsx", "syntaxKind": "PropertySignature", "name": "inputs", - "value": "{ key: string; } & OtherFormData", + "value": "{ key: any; } & OtherFormData", "description": "", "isOptional": true } @@ -982,14 +982,14 @@ "filePath": "/cart/CartForm.tsx", "syntaxKind": "TypeAliasDeclaration", "name": "CartFormCommonProps", - "value": "{\n /**\n * Children nodes of CartForm.\n * Children can be a render prop that receives the fetcher.\n */\n children:\n | React.ReactNode\n | ((fetcher: FetcherWithComponents) => React.ReactNode);\n /**\n * The route to submit the form to. Defaults to the current route.\n */\n route?: string;\n}", + "value": "{\n /**\n * Children nodes of CartForm.\n * Children can be a render prop that receives the fetcher.\n */\n children: ReactNode | ((fetcher: FetcherWithComponents) => ReactNode);\n /**\n * The route to submit the form to. Defaults to the current route.\n */\n route?: string;\n}", "description": "", "members": [ { "filePath": "/cart/CartForm.tsx", "syntaxKind": "PropertySignature", "name": "children", - "value": "any", + "value": "ReactNode | ((fetcher: FetcherWithComponents) => ReactNode)", "description": "Children nodes of CartForm. Children can be a render prop that receives the fetcher." }, { @@ -1023,7 +1023,7 @@ }, { "title": "TypeScript", - "code": "import {type ActionArgs, json} from '@remix-run/server-runtime';\nimport {\n type CartQueryData,\n type HydrogenCart,\n CartForm,\n} from '@shopify/hydrogen';\nimport invariant from 'tiny-invariant';\n\nexport default function Note() {\n return (\n <CartForm action={CartForm.ACTIONS.NoteUpdate}>\n <input type=\"text\" name=\"note\" />\n <button>Update Note</button>\n </CartForm>\n );\n}\n\nexport async function action({request, context}: ActionArgs) {\n const cart = context.cart as HydrogenCart;\n // cart is type HydrogenCart or HydrogenCartCustom\n // Declare cart type in remix.env.d.ts for interface AppLoadContext to avoid type casting\n // const {cart} = context;\n\n const formData = await request.formData();\n const {action, inputs} = CartForm.getFormInput(formData);\n\n let status = 200;\n let result: CartQueryData;\n\n if (action === CartForm.ACTIONS.NoteUpdate) {\n result = await cart.updateNote(inputs.note);\n } else {\n invariant(false, `${action} cart action is not defined`);\n }\n\n const headers = cart.setCartId(result.cart.id);\n\n return json(result, {status, headers});\n}\n", + "code": "import {type ActionFunctionArgs, json} from '@remix-run/server-runtime';\nimport {\n type CartQueryData,\n type HydrogenCart,\n CartForm,\n} from '@shopify/hydrogen';\nimport invariant from 'tiny-invariant';\n\nexport default function Note() {\n return (\n <CartForm action={CartForm.ACTIONS.NoteUpdate}>\n <input type=\"text\" name=\"note\" />\n <button>Update Note</button>\n </CartForm>\n );\n}\n\nexport async function action({request, context}: ActionFunctionArgs) {\n const cart = context.cart as HydrogenCart;\n // cart is type HydrogenCart or HydrogenCartCustom\n // Declare cart type in remix.env.d.ts for interface AppLoadContext to avoid type casting\n // const {cart} = context;\n\n const formData = await request.formData();\n const {action, inputs} = CartForm.getFormInput(formData);\n\n let status = 200;\n let result: CartQueryData;\n\n if (action === CartForm.ACTIONS.NoteUpdate) {\n result = await cart.updateNote(inputs.note);\n } else {\n invariant(false, `${action} cart action is not defined`);\n }\n\n const headers = cart.setCartId(result.cart.id);\n\n return json(result, {status, headers});\n}\n", "language": "tsx" } ] @@ -1046,7 +1046,7 @@ }, { "title": "TypeScript", - "code": "import {type ActionArgs, json} from '@remix-run/server-runtime';\nimport {\n type CartQueryData,\n type HydrogenCart,\n CartForm,\n} from '@shopify/hydrogen';\nimport {type CartLineInput} from '@shopify/hydrogen-react/storefront-api-types';\nimport invariant from 'tiny-invariant';\n\nexport default function Cart() {\n return (\n <CartForm\n action=\"CustomEditInPlace\"\n inputs={{\n addLines: [\n {\n merchandiseId: 'gid://shopify/Product/123456789',\n quantity: 1,\n },\n ],\n removeLines: ['gid://shopify/CartLine/123456789'],\n }}\n >\n <button>Green color swatch</button>\n </CartForm>\n );\n}\n\nexport async function action({request, context}: ActionArgs) {\n const cart = context.cart as HydrogenCart;\n // cart is type HydrogenCart or HydrogenCartCustom\n // Declare cart type in remix.env.d.ts for interface AppLoadContext to avoid type casting\n // const {cart} = context;\n\n const formData = await request.formData();\n const {action, inputs} = CartForm.getFormInput(formData);\n\n let status = 200;\n let result: CartQueryData;\n\n if (action === 'CustomEditInPlace') {\n result = await cart.addLines(inputs.addLines as CartLineInput[]);\n result = await cart.removeLines(inputs.removeLines as string[]);\n } else {\n invariant(false, `${action} cart action is not defined`);\n }\n\n const headers = cart.setCartId(result.cart.id);\n\n return json(result, {status, headers});\n}\n", + "code": "import {type ActionFunctionArgs, json} from '@remix-run/server-runtime';\nimport {\n type CartQueryData,\n type HydrogenCart,\n CartForm,\n} from '@shopify/hydrogen';\nimport {type CartLineInput} from '@shopify/hydrogen-react/storefront-api-types';\nimport invariant from 'tiny-invariant';\n\nexport default function Cart() {\n return (\n <CartForm\n action=\"CustomEditInPlace\"\n inputs={{\n addLines: [\n {\n merchandiseId: 'gid://shopify/Product/123456789',\n quantity: 1,\n },\n ],\n removeLines: ['gid://shopify/CartLine/123456789'],\n }}\n >\n <button>Green color swatch</button>\n </CartForm>\n );\n}\n\nexport async function action({request, context}: ActionFunctionArgs) {\n const cart = context.cart as HydrogenCart;\n // cart is type HydrogenCart or HydrogenCartCustom\n // Declare cart type in remix.env.d.ts for interface AppLoadContext to avoid type casting\n // const {cart} = context;\n\n const formData = await request.formData();\n const {action, inputs} = CartForm.getFormInput(formData);\n\n let status = 200;\n let result: CartQueryData;\n\n if (action === 'CustomEditInPlace') {\n result = await cart.addLines(inputs.addLines as CartLineInput[]);\n result = await cart.removeLines(inputs.removeLines as string[]);\n } else {\n invariant(false, `${action} cart action is not defined`);\n }\n\n const headers = cart.setCartId(result.cart.id);\n\n return json(result, {status, headers});\n}\n", "language": "tsx" } ] @@ -1069,7 +1069,7 @@ }, { "title": "TypeScript", - "code": "import {useFetcher} from '@remix-run/react';\nimport {type ActionArgs, json} from '@remix-run/server-runtime';\nimport {\n type CartQueryData,\n type HydrogenCart,\n CartForm,\n type CartActionInput,\n} from '@shopify/hydrogen';\nimport invariant from 'tiny-invariant';\nimport type {Cart} from '@shopify/hydrogen/storefront-api-types';\n\nexport function ThisIsGift({metafield}: {metafield: Cart['metafield']}) {\n const fetcher = useFetcher();\n\n const buildFormInput: (\n event: React.ChangeEvent<HTMLInputElement>,\n ) => CartActionInput = (event) => ({\n action: CartForm.ACTIONS.MetafieldsSet,\n inputs: {\n metafields: [\n {\n key: 'custom.gift',\n type: 'boolean',\n value: event.target.checked.toString(),\n },\n ],\n },\n });\n\n return (\n <div>\n <input\n checked={metafield?.value === 'true'}\n type=\"checkbox\"\n id=\"isGift\"\n onChange={(event) => {\n fetcher.submit(\n {\n [CartForm.INPUT_NAME]: JSON.stringify(buildFormInput(event)),\n },\n {method: 'POST', action: '/cart'},\n );\n }}\n />\n <label htmlFor=\"isGift\">This is a gift</label>\n </div>\n );\n}\n\nexport async function action({request, context}: ActionArgs) {\n const cart = context.cart as HydrogenCart;\n // cart is type HydrogenCart or HydrogenCartCustom\n // Declare cart type in remix.env.d.ts for interface AppLoadContext to avoid type casting\n // const {cart} = context;\n\n const formData = await request.formData();\n const {action, inputs} = CartForm.getFormInput(formData);\n\n let status = 200;\n let result: CartQueryData;\n\n if (action === CartForm.ACTIONS.MetafieldsSet) {\n result = await cart.setMetafields(inputs.metafields);\n } else {\n invariant(false, `${action} cart action is not defined`);\n }\n\n const headers = cart.setCartId(result.cart.id);\n\n return json(result, {status, headers});\n}\n", + "code": "import {useFetcher} from '@remix-run/react';\nimport {type ActionFunctionArgs, json} from '@remix-run/server-runtime';\nimport {\n type CartQueryData,\n type HydrogenCart,\n CartForm,\n type CartActionInput,\n} from '@shopify/hydrogen';\nimport invariant from 'tiny-invariant';\nimport type {Cart} from '@shopify/hydrogen/storefront-api-types';\n\nexport function ThisIsGift({metafield}: {metafield: Cart['metafield']}) {\n const fetcher = useFetcher();\n\n const buildFormInput: (\n event: React.ChangeEvent<HTMLInputElement>,\n ) => CartActionInput = (event) => ({\n action: CartForm.ACTIONS.MetafieldsSet,\n inputs: {\n metafields: [\n {\n key: 'custom.gift',\n type: 'boolean',\n value: event.target.checked.toString(),\n },\n ],\n },\n });\n\n return (\n <div>\n <input\n checked={metafield?.value === 'true'}\n type=\"checkbox\"\n id=\"isGift\"\n onChange={(event) => {\n fetcher.submit(\n {\n [CartForm.INPUT_NAME]: JSON.stringify(buildFormInput(event)),\n },\n {method: 'POST', action: '/cart'},\n );\n }}\n />\n <label htmlFor=\"isGift\">This is a gift</label>\n </div>\n );\n}\n\nexport async function action({request, context}: ActionFunctionArgs) {\n const cart = context.cart as HydrogenCart;\n // cart is type HydrogenCart or HydrogenCartCustom\n // Declare cart type in remix.env.d.ts for interface AppLoadContext to avoid type casting\n // const {cart} = context;\n\n const formData = await request.formData();\n const {action, inputs} = CartForm.getFormInput(formData);\n\n let status = 200;\n let result: CartQueryData;\n\n if (action === CartForm.ACTIONS.MetafieldsSet) {\n result = await cart.setMetafields(inputs.metafields);\n } else {\n invariant(false, `${action} cart action is not defined`);\n }\n\n const headers = cart.setCartId(result.cart.id);\n\n return json(result, {status, headers});\n}\n", "language": "tsx" } ] @@ -1288,7 +1288,7 @@ "filePath": "/cart/createCartHandler.ts", "syntaxKind": "TypeAliasDeclaration", "name": "CartHandlerOptionsForDocs", - "value": "{\n /**\n * A function that returns the cart id in the form of `gid://shopify/Cart/c1-123`.\n */\n getCartId: () => string | undefined;\n /**\n * A function that sets the cart ID.\n */\n setCartId: (cartId: string) => Headers;\n /**\n * The storefront client instance created by [`createStorefrontClient`](docs/api/hydrogen/latest/utilities/createstorefrontclient).\n */\n storefront: Storefront;\n /**\n * The cart mutation fragment used in most mutation requests, except for `setMetafields` and `deleteMetafield`.\n * See the [example usage](/docs/api/hydrogen/2023-10/utilities/createcarthandler#example-cart-fragments) in the documentation.\n */\n cartMutateFragment?: string;\n /**\n * The cart query fragment used by `cart.get()`.\n * See the [example usage](/docs/api/hydrogen/2023-10/utilities/createcarthandler#example-cart-fragments) in the documentation.\n */\n cartQueryFragment?: string;\n /**\n * Define custom methods or override existing methods for your cart API instance.\n * See the [example usage](/docs/api/hydrogen/2023-10/utilities/createcarthandler#example-custom-methods) in the documentation.\n */\n customMethods__unstable?: TCustomMethods;\n}", + "value": "{\n /**\n * A function that returns the cart id in the form of `gid://shopify/Cart/c1-123`.\n */\n getCartId: () => string | undefined;\n /**\n * A function that sets the cart ID.\n */\n setCartId: (cartId: string) => Headers;\n /**\n * The storefront client instance created by [`createStorefrontClient`](docs/api/hydrogen/latest/utilities/createstorefrontclient).\n */\n storefront: Storefront;\n /**\n * The cart mutation fragment used in most mutation requests, except for `setMetafields` and `deleteMetafield`.\n * See the [example usage](/docs/api/hydrogen/2023-10/utilities/createcarthandler#example-cart-fragments) in the documentation.\n */\n cartMutateFragment?: string;\n /**\n * The cart query fragment used by `cart.get()`.\n * See the [example usage](/docs/api/hydrogen/2023-10/utilities/createcarthandler#example-cart-fragments) in the documentation.\n */\n cartQueryFragment?: string;\n /**\n * Define custom methods or override existing methods for your cart API instance.\n * See the [example usage](/docs/api/hydrogen/2023-10/utilities/createcarthandler#example-custom-methods) in the documentation.\n */\n customMethods?: TCustomMethods;\n}", "description": "", "members": [ { @@ -1331,7 +1331,7 @@ { "filePath": "/cart/createCartHandler.ts", "syntaxKind": "PropertySignature", - "name": "customMethods__unstable", + "name": "customMethods", "value": "TCustomMethods", "description": "Define custom methods or override existing methods for your cart API instance. See the [example usage](/docs/api/hydrogen/2023-10/utilities/createcarthandler#example-custom-methods) in the documentation.", "isOptional": true @@ -1796,14 +1796,14 @@ "filePath": "/cart/queries/cart-types.ts", "syntaxKind": "TypeAliasDeclaration", "name": "CartOptionalInput", - "value": "{\n /**\n * The cart id.\n * @default cart.getCartId();\n */\n cartId?: Scalars['ID'];\n /**\n * The country code.\n * @default storefront.i18n.country\n */\n country?: CountryCode;\n /**\n * The language code.\n * @default storefront.i18n.language\n */\n language?: LanguageCode;\n}", + "value": "{\n /**\n * The cart id.\n * @default cart.getCartId();\n */\n cartId?: Scalars['ID']['input'];\n /**\n * The country code.\n * @default storefront.i18n.country\n */\n country?: CountryCode;\n /**\n * The language code.\n * @default storefront.i18n.language\n */\n language?: LanguageCode;\n}", "description": "", "members": [ { "filePath": "/cart/queries/cart-types.ts", "syntaxKind": "PropertySignature", "name": "cartId", - "value": "string", + "value": "any", "description": "The cart id.", "isOptional": true, "defaultValue": "cart.getCartId();" @@ -1922,7 +1922,7 @@ { "name": "key", "description": "", - "value": "string", + "value": "any", "filePath": "/cart/queries/cartMetafieldDeleteDefault.ts" }, { @@ -1939,7 +1939,7 @@ "name": "Promise", "value": "Promise" }, - "value": "export type CartMetafieldDeleteFunction = (\n key: Scalars['String'],\n optionalParams?: CartOptionalInput,\n) => Promise;" + "value": "export type CartMetafieldDeleteFunction = (\n key: Scalars['String']['input'],\n optionalParams?: CartOptionalInput,\n) => Promise;" }, "CartGetFunction": { "filePath": "/cart/queries/cartGetDefault.ts", @@ -2299,7 +2299,7 @@ "tabs": [ { "title": "JavaScript", - "code": "import {\n createCartHandler,\n cartGetIdDefault,\n cartSetIdDefault,\n cartLinesAddDefault,\n cartLinesRemoveDefault,\n} from '@shopify/hydrogen';\n\nconst cartQueryOptions = {\n storefront,\n getCartId: cartGetIdDefault(request.headers),\n};\n\nconst cart = createCartHandler({\n storefront,\n getCartId: cartGetIdDefault(request.headers),\n setCartId: cartSetIdDefault(),\n customMethods__unstable: {\n editInLine: async (addLines, removeLineIds, optionalParams) => {\n // Using Hydrogen default cart query methods\n await cartLinesAddDefault(cartQueryOptions)(addLines, optionalParams);\n return await cartLinesRemoveDefault(cartQueryOptions)(\n removeLineIds,\n optionalParams,\n );\n },\n addLines: async (lines, optionalParams) => {\n // With your own Storefront API graphql query\n return await storefront.mutate(CART_LINES_ADD_MUTATION, {\n variables: {\n id: optionalParams.cartId,\n lines,\n },\n });\n },\n },\n});\n\n// Use custom method editInLine that delete and add items in one method\ncart.editInLine(\n ['123'],\n [\n {\n merchandiseId: 'gid://shopify/ProductVariant/456789123',\n quantity: 1,\n },\n ],\n);\n\n// Use overridden cart.addLines\nconst result = await cart.addLines(\n [\n {\n merchandiseId: 'gid://shopify/ProductVariant/123456789',\n quantity: 1,\n },\n ],\n {\n cartId: 'c-123',\n },\n);\n// Output of result:\n// {\n// cartLinesAdd: {\n// cart: {\n// id: 'c-123',\n// totalQuantity: 1\n// },\n// errors: []\n// }\n// }\n\nconst CART_LINES_ADD_MUTATION = `#graphql\n mutation CartLinesAdd(\n $cartId: ID!\n $lines: [CartLineInput!]!\n $country: CountryCode = ZZ\n $language: LanguageCode\n ) @inContext(country: $country, language: $language) {\n cartLinesAdd(cartId: $cartId, lines: $lines) {\n cart {\n id\n totalQuantity\n }\n errors: userErrors {\n message\n field\n code\n }\n }\n }\n`;\n", + "code": "import {\n createCartHandler,\n cartGetIdDefault,\n cartSetIdDefault,\n cartLinesAddDefault,\n cartLinesRemoveDefault,\n} from '@shopify/hydrogen';\n\nconst cartQueryOptions = {\n storefront,\n getCartId: cartGetIdDefault(request.headers),\n};\n\nconst getCartId = cartGetIdDefault(request.headers);\n\nconst cart = createCartHandler({\n storefront,\n getCartId,\n setCartId: cartSetIdDefault(),\n customMethods: {\n editInLine: async (addLines, removeLineIds, optionalParams) => {\n // Using Hydrogen default cart query methods\n await cartLinesAddDefault(cartQueryOptions)(addLines, optionalParams);\n return await cartLinesRemoveDefault(cartQueryOptions)(\n removeLineIds,\n optionalParams,\n );\n },\n addLines: async (lines, optionalParams) => {\n // With your own Storefront API graphql query\n return await storefront.mutate(CART_LINES_ADD_MUTATION, {\n variables: {\n id: optionalParams.cartId || getCartId(),\n lines,\n },\n });\n },\n },\n});\n\n// Use custom method editInLine that delete and add items in one method\ncart.editInLine(\n ['123'],\n [\n {\n merchandiseId: 'gid://shopify/ProductVariant/456789123',\n quantity: 1,\n },\n ],\n);\n\n// Use overridden cart.addLines\nconst result = await cart.addLines(\n [\n {\n merchandiseId: 'gid://shopify/ProductVariant/123456789',\n quantity: 1,\n },\n ],\n {\n cartId: 'c-123',\n },\n);\n// Output of result:\n// {\n// cartLinesAdd: {\n// cart: {\n// id: 'c-123',\n// totalQuantity: 1\n// },\n// errors: []\n// }\n// }\n\nconst CART_LINES_ADD_MUTATION = `#graphql\n mutation CartLinesAdd(\n $cartId: ID!\n $lines: [CartLineInput!]!\n $country: CountryCode = ZZ\n $language: LanguageCode\n ) @inContext(country: $country, language: $language) {\n cartLinesAdd(cartId: $cartId, lines: $lines) {\n cart {\n id\n totalQuantity\n }\n errors: userErrors {\n message\n field\n code\n }\n }\n }\n`;\n", "language": "js" } ] @@ -2897,14 +2897,14 @@ "filePath": "/cart/queries/cart-types.ts", "syntaxKind": "TypeAliasDeclaration", "name": "CartOptionalInput", - "value": "{\n /**\n * The cart id.\n * @default cart.getCartId();\n */\n cartId?: Scalars['ID'];\n /**\n * The country code.\n * @default storefront.i18n.country\n */\n country?: CountryCode;\n /**\n * The language code.\n * @default storefront.i18n.language\n */\n language?: LanguageCode;\n}", + "value": "{\n /**\n * The cart id.\n * @default cart.getCartId();\n */\n cartId?: Scalars['ID']['input'];\n /**\n * The country code.\n * @default storefront.i18n.country\n */\n country?: CountryCode;\n /**\n * The language code.\n * @default storefront.i18n.language\n */\n language?: LanguageCode;\n}", "description": "", "members": [ { "filePath": "/cart/queries/cart-types.ts", "syntaxKind": "PropertySignature", "name": "cartId", - "value": "string", + "value": "any", "description": "The cart id.", "isOptional": true, "defaultValue": "cart.getCartId();" @@ -3385,14 +3385,14 @@ "filePath": "/cart/queries/cart-types.ts", "syntaxKind": "TypeAliasDeclaration", "name": "CartOptionalInput", - "value": "{\n /**\n * The cart id.\n * @default cart.getCartId();\n */\n cartId?: Scalars['ID'];\n /**\n * The country code.\n * @default storefront.i18n.country\n */\n country?: CountryCode;\n /**\n * The language code.\n * @default storefront.i18n.language\n */\n language?: LanguageCode;\n}", + "value": "{\n /**\n * The cart id.\n * @default cart.getCartId();\n */\n cartId?: Scalars['ID']['input'];\n /**\n * The country code.\n * @default storefront.i18n.country\n */\n country?: CountryCode;\n /**\n * The language code.\n * @default storefront.i18n.language\n */\n language?: LanguageCode;\n}", "description": "", "members": [ { "filePath": "/cart/queries/cart-types.ts", "syntaxKind": "PropertySignature", "name": "cartId", - "value": "string", + "value": "any", "description": "The cart id.", "isOptional": true, "defaultValue": "cart.getCartId();" @@ -3873,14 +3873,14 @@ "filePath": "/cart/queries/cart-types.ts", "syntaxKind": "TypeAliasDeclaration", "name": "CartOptionalInput", - "value": "{\n /**\n * The cart id.\n * @default cart.getCartId();\n */\n cartId?: Scalars['ID'];\n /**\n * The country code.\n * @default storefront.i18n.country\n */\n country?: CountryCode;\n /**\n * The language code.\n * @default storefront.i18n.language\n */\n language?: LanguageCode;\n}", + "value": "{\n /**\n * The cart id.\n * @default cart.getCartId();\n */\n cartId?: Scalars['ID']['input'];\n /**\n * The country code.\n * @default storefront.i18n.country\n */\n country?: CountryCode;\n /**\n * The language code.\n * @default storefront.i18n.language\n */\n language?: LanguageCode;\n}", "description": "", "members": [ { "filePath": "/cart/queries/cart-types.ts", "syntaxKind": "PropertySignature", "name": "cartId", - "value": "string", + "value": "any", "description": "The cart id.", "isOptional": true, "defaultValue": "cart.getCartId();" @@ -4354,14 +4354,14 @@ "filePath": "/cart/queries/cart-types.ts", "syntaxKind": "TypeAliasDeclaration", "name": "CartOptionalInput", - "value": "{\n /**\n * The cart id.\n * @default cart.getCartId();\n */\n cartId?: Scalars['ID'];\n /**\n * The country code.\n * @default storefront.i18n.country\n */\n country?: CountryCode;\n /**\n * The language code.\n * @default storefront.i18n.language\n */\n language?: LanguageCode;\n}", + "value": "{\n /**\n * The cart id.\n * @default cart.getCartId();\n */\n cartId?: Scalars['ID']['input'];\n /**\n * The country code.\n * @default storefront.i18n.country\n */\n country?: CountryCode;\n /**\n * The language code.\n * @default storefront.i18n.language\n */\n language?: LanguageCode;\n}", "description": "", "members": [ { "filePath": "/cart/queries/cart-types.ts", "syntaxKind": "PropertySignature", "name": "cartId", - "value": "string", + "value": "any", "description": "The cart id.", "isOptional": true, "defaultValue": "cart.getCartId();" @@ -5281,14 +5281,14 @@ "filePath": "/cart/queries/cart-types.ts", "syntaxKind": "TypeAliasDeclaration", "name": "CartOptionalInput", - "value": "{\n /**\n * The cart id.\n * @default cart.getCartId();\n */\n cartId?: Scalars['ID'];\n /**\n * The country code.\n * @default storefront.i18n.country\n */\n country?: CountryCode;\n /**\n * The language code.\n * @default storefront.i18n.language\n */\n language?: LanguageCode;\n}", + "value": "{\n /**\n * The cart id.\n * @default cart.getCartId();\n */\n cartId?: Scalars['ID']['input'];\n /**\n * The country code.\n * @default storefront.i18n.country\n */\n country?: CountryCode;\n /**\n * The language code.\n * @default storefront.i18n.language\n */\n language?: LanguageCode;\n}", "description": "", "members": [ { "filePath": "/cart/queries/cart-types.ts", "syntaxKind": "PropertySignature", "name": "cartId", - "value": "string", + "value": "any", "description": "The cart id.", "isOptional": true, "defaultValue": "cart.getCartId();" @@ -5762,14 +5762,14 @@ "filePath": "/cart/queries/cart-types.ts", "syntaxKind": "TypeAliasDeclaration", "name": "CartOptionalInput", - "value": "{\n /**\n * The cart id.\n * @default cart.getCartId();\n */\n cartId?: Scalars['ID'];\n /**\n * The country code.\n * @default storefront.i18n.country\n */\n country?: CountryCode;\n /**\n * The language code.\n * @default storefront.i18n.language\n */\n language?: LanguageCode;\n}", + "value": "{\n /**\n * The cart id.\n * @default cart.getCartId();\n */\n cartId?: Scalars['ID']['input'];\n /**\n * The country code.\n * @default storefront.i18n.country\n */\n country?: CountryCode;\n /**\n * The language code.\n * @default storefront.i18n.language\n */\n language?: LanguageCode;\n}", "description": "", "members": [ { "filePath": "/cart/queries/cart-types.ts", "syntaxKind": "PropertySignature", "name": "cartId", - "value": "string", + "value": "any", "description": "The cart id.", "isOptional": true, "defaultValue": "cart.getCartId();" @@ -6250,14 +6250,14 @@ "filePath": "/cart/queries/cart-types.ts", "syntaxKind": "TypeAliasDeclaration", "name": "CartOptionalInput", - "value": "{\n /**\n * The cart id.\n * @default cart.getCartId();\n */\n cartId?: Scalars['ID'];\n /**\n * The country code.\n * @default storefront.i18n.country\n */\n country?: CountryCode;\n /**\n * The language code.\n * @default storefront.i18n.language\n */\n language?: LanguageCode;\n}", + "value": "{\n /**\n * The cart id.\n * @default cart.getCartId();\n */\n cartId?: Scalars['ID']['input'];\n /**\n * The country code.\n * @default storefront.i18n.country\n */\n country?: CountryCode;\n /**\n * The language code.\n * @default storefront.i18n.language\n */\n language?: LanguageCode;\n}", "description": "", "members": [ { "filePath": "/cart/queries/cart-types.ts", "syntaxKind": "PropertySignature", "name": "cartId", - "value": "string", + "value": "any", "description": "The cart id.", "isOptional": true, "defaultValue": "cart.getCartId();" @@ -6708,7 +6708,7 @@ { "name": "key", "description": "", - "value": "string", + "value": "any", "filePath": "/cart/queries/cartMetafieldDeleteDefault.ts" }, { @@ -6725,20 +6725,20 @@ "name": "Promise", "value": "Promise" }, - "value": "export type CartMetafieldDeleteFunction = (\n key: Scalars['String'],\n optionalParams?: CartOptionalInput,\n) => Promise;" + "value": "export type CartMetafieldDeleteFunction = (\n key: Scalars['String']['input'],\n optionalParams?: CartOptionalInput,\n) => Promise;" }, "CartOptionalInput": { "filePath": "/cart/queries/cart-types.ts", "syntaxKind": "TypeAliasDeclaration", "name": "CartOptionalInput", - "value": "{\n /**\n * The cart id.\n * @default cart.getCartId();\n */\n cartId?: Scalars['ID'];\n /**\n * The country code.\n * @default storefront.i18n.country\n */\n country?: CountryCode;\n /**\n * The language code.\n * @default storefront.i18n.language\n */\n language?: LanguageCode;\n}", + "value": "{\n /**\n * The cart id.\n * @default cart.getCartId();\n */\n cartId?: Scalars['ID']['input'];\n /**\n * The country code.\n * @default storefront.i18n.country\n */\n country?: CountryCode;\n /**\n * The language code.\n * @default storefront.i18n.language\n */\n language?: LanguageCode;\n}", "description": "", "members": [ { "filePath": "/cart/queries/cart-types.ts", "syntaxKind": "PropertySignature", "name": "cartId", - "value": "string", + "value": "any", "description": "The cart id.", "isOptional": true, "defaultValue": "cart.getCartId();" @@ -7219,14 +7219,14 @@ "filePath": "/cart/queries/cart-types.ts", "syntaxKind": "TypeAliasDeclaration", "name": "CartOptionalInput", - "value": "{\n /**\n * The cart id.\n * @default cart.getCartId();\n */\n cartId?: Scalars['ID'];\n /**\n * The country code.\n * @default storefront.i18n.country\n */\n country?: CountryCode;\n /**\n * The language code.\n * @default storefront.i18n.language\n */\n language?: LanguageCode;\n}", + "value": "{\n /**\n * The cart id.\n * @default cart.getCartId();\n */\n cartId?: Scalars['ID']['input'];\n /**\n * The country code.\n * @default storefront.i18n.country\n */\n country?: CountryCode;\n /**\n * The language code.\n * @default storefront.i18n.language\n */\n language?: LanguageCode;\n}", "description": "", "members": [ { "filePath": "/cart/queries/cart-types.ts", "syntaxKind": "PropertySignature", "name": "cartId", - "value": "string", + "value": "any", "description": "The cart id.", "isOptional": true, "defaultValue": "cart.getCartId();" @@ -7700,14 +7700,14 @@ "filePath": "/cart/queries/cart-types.ts", "syntaxKind": "TypeAliasDeclaration", "name": "CartOptionalInput", - "value": "{\n /**\n * The cart id.\n * @default cart.getCartId();\n */\n cartId?: Scalars['ID'];\n /**\n * The country code.\n * @default storefront.i18n.country\n */\n country?: CountryCode;\n /**\n * The language code.\n * @default storefront.i18n.language\n */\n language?: LanguageCode;\n}", + "value": "{\n /**\n * The cart id.\n * @default cart.getCartId();\n */\n cartId?: Scalars['ID']['input'];\n /**\n * The country code.\n * @default storefront.i18n.country\n */\n country?: CountryCode;\n /**\n * The language code.\n * @default storefront.i18n.language\n */\n language?: LanguageCode;\n}", "description": "", "members": [ { "filePath": "/cart/queries/cart-types.ts", "syntaxKind": "PropertySignature", "name": "cartId", - "value": "string", + "value": "any", "description": "The cart id.", "isOptional": true, "defaultValue": "cart.getCartId();" @@ -8188,14 +8188,14 @@ "filePath": "/cart/queries/cart-types.ts", "syntaxKind": "TypeAliasDeclaration", "name": "CartOptionalInput", - "value": "{\n /**\n * The cart id.\n * @default cart.getCartId();\n */\n cartId?: Scalars['ID'];\n /**\n * The country code.\n * @default storefront.i18n.country\n */\n country?: CountryCode;\n /**\n * The language code.\n * @default storefront.i18n.language\n */\n language?: LanguageCode;\n}", + "value": "{\n /**\n * The cart id.\n * @default cart.getCartId();\n */\n cartId?: Scalars['ID']['input'];\n /**\n * The country code.\n * @default storefront.i18n.country\n */\n country?: CountryCode;\n /**\n * The language code.\n * @default storefront.i18n.language\n */\n language?: LanguageCode;\n}", "description": "", "members": [ { "filePath": "/cart/queries/cart-types.ts", "syntaxKind": "PropertySignature", "name": "cartId", - "value": "string", + "value": "any", "description": "The cart id.", "isOptional": true, "defaultValue": "cart.getCartId();" @@ -8351,7 +8351,7 @@ "name": "StorefrontClient", "value": "StorefrontClient" }, - "value": "export function createStorefrontClient(\n options: CreateStorefrontClientOptions,\n): StorefrontClient {\n const {\n storefrontHeaders,\n cache,\n waitUntil,\n buyerIp,\n i18n,\n requestGroupId,\n storefrontId,\n ...clientOptions\n } = options;\n const H2_PREFIX_WARN = '[h2:warn:createStorefrontClient] ';\n\n if (process.env.NODE_ENV === 'development' && !cache) {\n warnOnce(\n H2_PREFIX_WARN +\n 'Storefront API client created without a cache instance. This may slow down your sub-requests.',\n );\n }\n\n const {\n getPublicTokenHeaders,\n getPrivateTokenHeaders,\n getStorefrontApiUrl,\n getShopifyDomain,\n } = createStorefrontUtilities(clientOptions);\n\n const getHeaders = clientOptions.privateStorefrontToken\n ? getPrivateTokenHeaders\n : getPublicTokenHeaders;\n\n const defaultHeaders = getHeaders({\n contentType: 'json',\n buyerIp: storefrontHeaders?.buyerIp || buyerIp,\n });\n\n defaultHeaders[STOREFRONT_REQUEST_GROUP_ID_HEADER] =\n storefrontHeaders?.requestGroupId || requestGroupId || generateUUID();\n\n if (storefrontId) defaultHeaders[SHOPIFY_STOREFRONT_ID_HEADER] = storefrontId;\n if (LIB_VERSION) defaultHeaders['user-agent'] = `Hydrogen ${LIB_VERSION}`;\n\n if (storefrontHeaders && storefrontHeaders.cookie) {\n const cookies = getShopifyCookies(storefrontHeaders.cookie ?? '');\n\n if (cookies[SHOPIFY_Y])\n defaultHeaders[SHOPIFY_STOREFRONT_Y_HEADER] = cookies[SHOPIFY_Y];\n if (cookies[SHOPIFY_S])\n defaultHeaders[SHOPIFY_STOREFRONT_S_HEADER] = cookies[SHOPIFY_S];\n }\n\n // Deprecation warning\n if (process.env.NODE_ENV === 'development' && !storefrontHeaders) {\n warnOnce(\n H2_PREFIX_WARN +\n '`requestGroupId` and `buyerIp` will be deprecated in the next calendar release. Please use `getStorefrontHeaders`',\n );\n }\n\n async function fetchStorefrontApi({\n query,\n mutation,\n variables,\n cache: cacheOptions,\n headers = [],\n storefrontApiVersion,\n }: StorefrontQueryOptions | StorefrontMutationOptions): Promise {\n const userHeaders =\n headers instanceof Headers\n ? Object.fromEntries(headers.entries())\n : Array.isArray(headers)\n ? Object.fromEntries(headers)\n : headers;\n\n query = query ?? mutation;\n\n const queryVariables = {...variables};\n\n if (i18n) {\n if (!variables?.country && /\\$country/.test(query)) {\n queryVariables.country = i18n.country;\n }\n\n if (!variables?.language && /\\$language/.test(query)) {\n queryVariables.language = i18n.language;\n }\n }\n\n const url = getStorefrontApiUrl({storefrontApiVersion});\n const graphqlData = JSON.stringify({query, variables: queryVariables});\n const requestInit: RequestInit = {\n method: 'POST',\n headers: {...defaultHeaders, ...userHeaders},\n body: graphqlData,\n };\n\n // Remove any headers that are identifiable to the user or request\n const cacheKey = [\n url,\n {\n method: requestInit.method,\n headers: {\n 'content-type': defaultHeaders['content-type'],\n 'X-SDK-Variant': defaultHeaders['X-SDK-Variant'],\n 'X-SDK-Variant-Source': defaultHeaders['X-SDK-Variant-Source'],\n 'X-SDK-Version': defaultHeaders['X-SDK-Version'],\n 'X-Shopify-Storefront-Access-Token':\n defaultHeaders['X-Shopify-Storefront-Access-Token'],\n 'user-agent': defaultHeaders['user-agent'],\n },\n body: requestInit.body,\n },\n ];\n\n const [body, response] = await fetchWithServerCache(url, requestInit, {\n cacheInstance: mutation ? undefined : cache,\n cache: cacheOptions || CacheShort(),\n cacheKey,\n shouldCacheResponse: checkGraphQLErrors,\n waitUntil,\n debugInfo: {graphql: graphqlData},\n });\n\n const errorOptions: StorefrontErrorOptions = {\n response,\n type: mutation ? 'mutation' : 'query',\n query,\n queryVariables,\n errors: undefined,\n };\n\n if (!response.ok) {\n /**\n * The Storefront API might return a string error, or a JSON-formatted {error: string}.\n * We try both and conform them to a single {errors} format.\n */\n let errors;\n try {\n errors = parseJSON(body);\n } catch (_e) {\n errors = [{message: body}];\n }\n\n throwError({...errorOptions, errors});\n }\n\n const {data, errors} = body as StorefrontApiResponse;\n\n if (errors?.length) {\n throwError({\n ...errorOptions,\n errors,\n ErrorConstructor: StorefrontApiError,\n });\n }\n\n return data as T;\n }\n\n return {\n storefront: {\n /**\n * Sends a GraphQL query to the Storefront API.\n *\n * Example:\n *\n * ```js\n * async function loader ({context: {storefront}}) {\n * const data = await storefront.query('query { ... }', {\n * variables: {},\n * cache: storefront.CacheLong()\n * });\n * }\n * ```\n */\n query: ((query: string, payload) => {\n query = minifyQuery(query);\n if (isMutationRE.test(query)) {\n throw new Error(\n '[h2:error:storefront.query] Cannot execute mutations',\n );\n }\n\n const result = fetchStorefrontApi({\n ...payload,\n query,\n });\n\n // This is a no-op, but we need to catch the promise to avoid unhandled rejections\n // we cannot return the catch no-op, or it would swallow the error\n result.catch(() => {});\n\n return result;\n }),\n /**\n * Sends a GraphQL mutation to the Storefront API.\n *\n * Example:\n *\n * ```js\n * async function loader ({context: {storefront}}) {\n * await storefront.mutate('mutation { ... }', {\n * variables: {},\n * });\n * }\n * ```\n */\n mutate: ((mutation: string, payload) => {\n mutation = minifyQuery(mutation);\n if (isQueryRE.test(mutation)) {\n throw new Error(\n '[h2:error:storefront.mutate] Cannot execute queries',\n );\n }\n\n const result = fetchStorefrontApi({\n ...payload,\n mutation,\n });\n\n // This is a no-op, but we need to catch the promise to avoid unhandled rejections\n // we cannot return the catch no-op, or it would swallow the error\n result.catch(() => {});\n\n return result;\n }),\n cache,\n CacheNone,\n CacheLong,\n CacheShort,\n CacheCustom,\n generateCacheControlHeader,\n getPublicTokenHeaders,\n getPrivateTokenHeaders,\n getShopifyDomain,\n getApiUrl: getStorefrontApiUrl,\n /**\n * Wether it's a GraphQL error returned in the Storefront API response.\n *\n * Example:\n *\n * ```js\n * async function loader ({context: {storefront}}) {\n * try {\n * await storefront.query(...);\n * } catch(error) {\n * if (storefront.isApiError(error)) {\n * // ...\n * }\n *\n * throw error;\n * }\n * }\n * ```\n */\n isApiError: isStorefrontApiError,\n i18n: (i18n ?? defaultI18n) as TI18n,\n },\n };\n}" + "value": "export function createStorefrontClient(\n options: CreateStorefrontClientOptions,\n): StorefrontClient {\n const {\n storefrontHeaders,\n cache,\n waitUntil,\n i18n,\n storefrontId,\n ...clientOptions\n } = options;\n const H2_PREFIX_WARN = '[h2:warn:createStorefrontClient] ';\n\n if (process.env.NODE_ENV === 'development' && !cache) {\n warnOnce(\n H2_PREFIX_WARN +\n 'Storefront API client created without a cache instance. This may slow down your sub-requests.',\n );\n }\n\n const {\n getPublicTokenHeaders,\n getPrivateTokenHeaders,\n getStorefrontApiUrl,\n getShopifyDomain,\n } = createStorefrontUtilities(clientOptions);\n\n const getHeaders = clientOptions.privateStorefrontToken\n ? getPrivateTokenHeaders\n : getPublicTokenHeaders;\n\n const defaultHeaders = getHeaders({\n contentType: 'json',\n buyerIp: storefrontHeaders?.buyerIp || '',\n });\n\n defaultHeaders[STOREFRONT_REQUEST_GROUP_ID_HEADER] =\n storefrontHeaders?.requestGroupId || generateUUID();\n\n if (storefrontId) defaultHeaders[SHOPIFY_STOREFRONT_ID_HEADER] = storefrontId;\n if (LIB_VERSION) defaultHeaders['user-agent'] = `Hydrogen ${LIB_VERSION}`;\n\n if (storefrontHeaders && storefrontHeaders.cookie) {\n const cookies = getShopifyCookies(storefrontHeaders.cookie ?? '');\n\n if (cookies[SHOPIFY_Y])\n defaultHeaders[SHOPIFY_STOREFRONT_Y_HEADER] = cookies[SHOPIFY_Y];\n if (cookies[SHOPIFY_S])\n defaultHeaders[SHOPIFY_STOREFRONT_S_HEADER] = cookies[SHOPIFY_S];\n }\n\n async function fetchStorefrontApi({\n query,\n mutation,\n variables,\n cache: cacheOptions,\n headers = [],\n storefrontApiVersion,\n }: StorefrontQueryOptions | StorefrontMutationOptions): Promise {\n const userHeaders =\n headers instanceof Headers\n ? Object.fromEntries(headers.entries())\n : Array.isArray(headers)\n ? Object.fromEntries(headers)\n : headers;\n\n query = query ?? mutation;\n\n const queryVariables = {...variables};\n\n if (i18n) {\n if (!variables?.country && /\\$country/.test(query)) {\n queryVariables.country = i18n.country;\n }\n\n if (!variables?.language && /\\$language/.test(query)) {\n queryVariables.language = i18n.language;\n }\n }\n\n const url = getStorefrontApiUrl({storefrontApiVersion});\n const graphqlData = JSON.stringify({query, variables: queryVariables});\n const requestInit = {\n method: 'POST',\n headers: {...defaultHeaders, ...userHeaders},\n body: graphqlData,\n } satisfies RequestInit;\n\n // Remove any headers that are identifiable to the user or request\n const cacheKey = [\n url,\n {\n method: requestInit.method,\n headers: {\n 'content-type': defaultHeaders['content-type'],\n 'user-agent': defaultHeaders['user-agent'],\n [SDK_VARIANT_HEADER]: defaultHeaders[SDK_VARIANT_HEADER],\n [SDK_VARIANT_SOURCE_HEADER]:\n defaultHeaders[SDK_VARIANT_SOURCE_HEADER],\n [SDK_VERSION_HEADER]: defaultHeaders[SDK_VERSION_HEADER],\n [STOREFRONT_ACCESS_TOKEN_HEADER]:\n defaultHeaders[STOREFRONT_ACCESS_TOKEN_HEADER],\n },\n body: requestInit.body,\n },\n ];\n\n const [body, response] = await fetchWithServerCache(url, requestInit, {\n cacheInstance: mutation ? undefined : cache,\n cache: cacheOptions || CacheDefault(),\n cacheKey,\n shouldCacheResponse: checkGraphQLErrors,\n waitUntil,\n debugInfo: {\n graphql: graphqlData,\n requestId: requestInit.headers[STOREFRONT_REQUEST_GROUP_ID_HEADER],\n purpose: storefrontHeaders?.purpose,\n },\n });\n\n const errorOptions: GraphQLErrorOptions = {\n response,\n type: mutation ? 'mutation' : 'query',\n query,\n queryVariables,\n errors: undefined,\n };\n\n if (!response.ok) {\n /**\n * The Storefront API might return a string error, or a JSON-formatted {error: string}.\n * We try both and conform them to a single {errors} format.\n */\n let errors;\n try {\n errors = parseJSON(body);\n } catch (_e) {\n errors = [{message: body}];\n }\n\n throwGraphQLError({...errorOptions, errors});\n }\n\n const {data, errors} = body as GraphQLApiResponse;\n\n if (errors?.length) {\n throwGraphQLError({\n ...errorOptions,\n errors,\n ErrorConstructor: StorefrontApiError,\n });\n }\n\n return data as T;\n }\n\n return {\n storefront: {\n /**\n * Sends a GraphQL query to the Storefront API.\n *\n * Example:\n *\n * ```js\n * async function loader ({context: {storefront}}) {\n * const data = await storefront.query('query { ... }', {\n * variables: {},\n * cache: storefront.CacheLong()\n * });\n * }\n * ```\n */\n query: ((query: string, payload) => {\n query = minifyQuery(query);\n assertQuery(query, 'storefront.query');\n\n const result = fetchStorefrontApi({\n ...payload,\n query,\n });\n\n // This is a no-op, but we need to catch the promise to avoid unhandled rejections\n // we cannot return the catch no-op, or it would swallow the error\n result.catch(() => {});\n\n return result;\n }),\n /**\n * Sends a GraphQL mutation to the Storefront API.\n *\n * Example:\n *\n * ```js\n * async function loader ({context: {storefront}}) {\n * await storefront.mutate('mutation { ... }', {\n * variables: {},\n * });\n * }\n * ```\n */\n mutate: ((mutation: string, payload) => {\n mutation = minifyQuery(mutation);\n assertMutation(mutation, 'storefront.mutate');\n\n const result = fetchStorefrontApi({\n ...payload,\n mutation,\n });\n\n // This is a no-op, but we need to catch the promise to avoid unhandled rejections\n // we cannot return the catch no-op, or it would swallow the error\n result.catch(() => {});\n\n return result;\n }),\n cache,\n CacheNone,\n CacheLong,\n CacheShort,\n CacheCustom,\n generateCacheControlHeader,\n getPublicTokenHeaders,\n getPrivateTokenHeaders,\n getShopifyDomain,\n getApiUrl: getStorefrontApiUrl,\n /**\n * Wether it's a GraphQL error returned in the Storefront API response.\n *\n * Example:\n *\n * ```js\n * async function loader ({context: {storefront}}) {\n * try {\n * await storefront.query(...);\n * } catch(error) {\n * if (storefront.isApiError(error)) {\n * // ...\n * }\n *\n * throw error;\n * }\n * }\n * ```\n */\n isApiError: isStorefrontApiError,\n i18n: (i18n ?? defaultI18n) as TI18n,\n },\n };\n}" }, "CreateStorefrontClientOptions": { "filePath": "/storefront.ts", @@ -8364,7 +8364,7 @@ "filePath": "/storefront.ts", "syntaxKind": "TypeAliasDeclaration", "name": "HydrogenClientProps", - "value": "{\n /** Storefront API headers. If on Oxygen, use `getStorefrontHeaders()` */\n storefrontHeaders?: StorefrontHeaders;\n /** An instance that implements the [Cache API](https://developer.mozilla.org/en-US/docs/Web/API/Cache) */\n cache?: Cache;\n /** @deprecated use storefrontHeaders instead */\n buyerIp?: string;\n /** @deprecated use storefrontHeaders instead */\n requestGroupId?: string | null;\n /** The globally unique identifier for the Shop */\n storefrontId?: string;\n /** The `waitUntil` function is used to keep the current request/response lifecycle alive even after a response has been sent. It should be provided by your platform. */\n waitUntil?: ExecutionContext['waitUntil'];\n /** An object containing a country code and language code */\n i18n?: TI18n;\n}", + "value": "{\n /** Storefront API headers. If on Oxygen, use `getStorefrontHeaders()` */\n storefrontHeaders?: StorefrontHeaders;\n /** An instance that implements the [Cache API](https://developer.mozilla.org/en-US/docs/Web/API/Cache) */\n cache?: Cache;\n /** The globally unique identifier for the Shop */\n storefrontId?: string;\n /** The `waitUntil` function is used to keep the current request/response lifecycle alive even after a response has been sent. It should be provided by your platform. */\n waitUntil?: ExecutionContext['waitUntil'];\n /** An object containing a country code and language code */\n i18n?: TI18n;\n}", "description": "", "members": [ { @@ -8383,24 +8383,6 @@ "description": "An instance that implements the [Cache API](https://developer.mozilla.org/en-US/docs/Web/API/Cache)", "isOptional": true }, - { - "filePath": "/storefront.ts", - "syntaxKind": "PropertySignature", - "name": "buyerIp", - "value": "string", - "description": "", - "isOptional": true, - "deprecationMessage": "use storefrontHeaders instead" - }, - { - "filePath": "/storefront.ts", - "syntaxKind": "PropertySignature", - "name": "requestGroupId", - "value": "string", - "description": "", - "isOptional": true, - "deprecationMessage": "use storefrontHeaders instead" - }, { "filePath": "/storefront.ts", "syntaxKind": "PropertySignature", @@ -8431,7 +8413,7 @@ "filePath": "/storefront.ts", "syntaxKind": "TypeAliasDeclaration", "name": "StorefrontHeaders", - "value": "{\n /** A unique ID that correlates all sub-requests together. */\n requestGroupId: string | null;\n /** The IP address of the client. */\n buyerIp: string | null;\n /** The cookie header from the client */\n cookie: string | null;\n}", + "value": "{\n /** A unique ID that correlates all sub-requests together. */\n requestGroupId: string | null;\n /** The IP address of the client. */\n buyerIp: string | null;\n /** The cookie header from the client */\n cookie: string | null;\n /** The purpose header value for debugging */\n purpose: string | null;\n}", "description": "", "members": [ { @@ -8454,6 +8436,13 @@ "name": "cookie", "value": "string", "description": "The cookie header from the client" + }, + { + "filePath": "/storefront.ts", + "syntaxKind": "PropertySignature", + "name": "purpose", + "value": "string", + "description": "The purpose header value for debugging" } ] }, @@ -8995,6 +8984,239 @@ } ] }, + { + "name": "createCustomerClient", + "category": "utilities", + "isVisualComponent": false, + "related": [ + { + "name": "createStorefrontClient", + "type": "utility", + "url": "/docs/api/hydrogen/2023-07/utilities/createstorefrontclient" + } + ], + "description": "> Caution:\n> This component is in an unstable pre-release state and may have breaking changes in a future release.\n\nThe `createCustomerClient` function creates a GraphQL client for querying the [Customer Account API](https://shopify.dev/docs/api/customer). It also provides methods to authenticate and check if the user is logged in.\n\nSee an end to end [example on using the Customer Account API client](https://github.com/Shopify/hydrogen/tree/main/examples/customer-api).", + "type": "utility", + "defaultExample": { + "description": "I am the default example", + "codeblock": { + "tabs": [ + { + "title": "JavaScript", + "code": "import {createCustomerClient__unstable} from '@shopify/hydrogen';\nimport * as remixBuild from '@remix-run/dev/server-build';\nimport {\n createRequestHandler,\n createCookieSessionStorage,\n} from '@shopify/remix-oxygen';\n\nexport default {\n async fetch(request, env, executionContext) {\n const session = await HydrogenSession.init(request, [env.SESSION_SECRET]);\n\n /* Create a Customer API client with your credentials and options */\n const customer = createCustomerClient__unstable({\n /* Runtime utility in serverless environments */\n waitUntil: (p) => executionContext.waitUntil(p),\n /* Public Customer Account API token for your store */\n customerAccountId: env.PUBLIC_CUSTOMER_ACCOUNT_ID,\n /* Public account URL for your store */\n customerAccountUrl: env.PUBLIC_CUSTOMER_ACCOUNT_URL,\n request,\n session,\n });\n\n const handleRequest = createRequestHandler({\n build: remixBuild,\n mode: process.env.NODE_ENV,\n /* Inject the customer account client in the Remix context */\n getLoadContext: () => ({customer}),\n });\n\n return handleRequest(request);\n },\n};\n\nclass HydrogenSession {\n static async init(request, secrets) {\n const storage = createCookieSessionStorage({\n cookie: {\n name: 'session',\n httpOnly: true,\n path: '/',\n sameSite: 'lax',\n secrets,\n },\n });\n\n const session = await storage.getSession(request.headers.get('Cookie'));\n\n return new this(storage, session);\n }\n\n get(key) {\n return this.session.get(key);\n }\n\n destroy() {\n return this.sessionStorage.destroySession(this.session);\n }\n\n flash(key, value) {\n this.session.flash(key, value);\n }\n\n unset(key) {\n this.session.unset(key);\n }\n\n set(key, value) {\n this.session.set(key, value);\n }\n\n commit() {\n return this.sessionStorage.commitSession(this.session);\n }\n}\n", + "language": "jsx" + }, + { + "title": "TypeScript", + "code": "import {createCustomerClient__unstable} from '@shopify/hydrogen';\nimport * as remixBuild from '@remix-run/dev/server-build';\nimport {\n createRequestHandler,\n createCookieSessionStorage,\n type SessionStorage,\n type Session,\n} from '@shopify/remix-oxygen';\n\nexport default {\n async fetch(\n request: Request,\n env: Record<string, string>,\n executionContext: ExecutionContext,\n ) {\n const session = await HydrogenSession.init(request, [env.SESSION_SECRET]);\n\n /* Create a Customer API client with your credentials and options */\n const customer = createCustomerClient__unstable({\n /* Runtime utility in serverless environments */\n waitUntil: (p) => executionContext.waitUntil(p),\n /* Public Customer Account API client ID for your store */\n customerAccountId: env.PUBLIC_CUSTOMER_ACCOUNT_ID,\n /* Public account URL for your store */\n customerAccountUrl: env.PUBLIC_CUSTOMER_ACCOUNT_URL,\n request,\n session,\n });\n\n const handleRequest = createRequestHandler({\n build: remixBuild,\n mode: process.env.NODE_ENV,\n /* Inject the customer account client in the Remix context */\n getLoadContext: () => ({customer}),\n });\n\n return handleRequest(request);\n },\n};\n\nclass HydrogenSession {\n constructor(\n private sessionStorage: SessionStorage,\n private session: Session,\n ) {}\n\n static async init(request: Request, secrets: string[]) {\n const storage = createCookieSessionStorage({\n cookie: {\n name: 'session',\n httpOnly: true,\n path: '/',\n sameSite: 'lax',\n secrets,\n },\n });\n\n const session = await storage.getSession(request.headers.get('Cookie'));\n\n return new this(storage, session);\n }\n\n get(key: string) {\n return this.session.get(key);\n }\n\n destroy() {\n return this.sessionStorage.destroySession(this.session);\n }\n\n flash(key: string, value: any) {\n this.session.flash(key, value);\n }\n\n unset(key: string) {\n this.session.unset(key);\n }\n\n set(key: string, value: any) {\n this.session.set(key, value);\n }\n\n commit() {\n return this.sessionStorage.commitSession(this.session);\n }\n}\n", + "language": "tsx" + } + ], + "title": "Example code" + } + }, + "definitions": [ + { + "title": "Props", + "description": "", + "type": "CreateCustomerClientGeneratedType", + "typeDefinitions": { + "CreateCustomerClientGeneratedType": { + "filePath": "/customer/customer.ts", + "name": "CreateCustomerClientGeneratedType", + "description": "", + "params": [ + { + "name": "input1", + "description": "", + "value": "CustomerClientOptions", + "filePath": "/customer/customer.ts" + } + ], + "returns": { + "filePath": "/customer/customer.ts", + "description": "", + "name": "CustomerClient", + "value": "CustomerClient" + }, + "value": "export function createCustomerClient({\n session,\n customerAccountId,\n customerAccountUrl,\n customerApiVersion = '2023-10',\n request,\n waitUntil,\n}: CustomerClientOptions): CustomerClient {\n if (!request?.url) {\n throw new Error(\n '[h2:error:createCustomerClient] The request object does not contain a URL.',\n );\n }\n const url = new URL(request.url);\n const origin =\n url.protocol === 'http:' ? url.origin.replace('http', 'https') : url.origin;\n\n const locks: Locks = {};\n\n const logSubRequestEvent =\n process.env.NODE_ENV === 'development'\n ? (query: string, startTime: number) => {\n globalThis.__H2O_LOG_EVENT?.({\n eventType: 'subrequest',\n url: `https://shopify.dev/?${hashKey([\n `Customer Account `,\n /((query|mutation) [^\\s\\(]+)/g.exec(query)?.[0] ||\n query.substring(0, 10),\n ])}`,\n startTime,\n waitUntil,\n ...getDebugHeaders(request),\n });\n }\n : undefined;\n\n async function fetchCustomerAPI({\n query,\n type,\n variables = {},\n }: {\n query: string;\n type: 'query' | 'mutation';\n variables?: Record;\n }) {\n const accessToken = session.get('customer_access_token');\n const expiresAt = session.get('expires_at');\n\n if (!accessToken || !expiresAt)\n throw new BadRequest(\n 'Unauthorized',\n 'Login before querying the Customer Account API.',\n );\n\n await checkExpires({\n locks,\n expiresAt,\n session,\n customerAccountId,\n customerAccountUrl,\n origin,\n });\n\n const startTime = new Date().getTime();\n\n const response = await fetch(\n `${customerAccountUrl}/account/customer/api/${customerApiVersion}/graphql`,\n {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'User-Agent': USER_AGENT,\n Origin: origin,\n Authorization: accessToken,\n },\n body: JSON.stringify({\n operationName: 'SomeQuery',\n query,\n variables,\n }),\n },\n );\n\n logSubRequestEvent?.(query, startTime);\n\n const body = await response.text();\n\n const errorOptions: GraphQLErrorOptions = {\n response,\n type,\n query,\n queryVariables: variables,\n errors: undefined,\n client: 'customer',\n };\n\n if (!response.ok) {\n /**\n * The Customer API might return a string error, or a JSON-formatted {error: string}.\n * We try both and conform them to a single {errors} format.\n */\n let errors;\n try {\n errors = parseJSON(body);\n } catch (_e) {\n errors = [{message: body}];\n }\n\n throwGraphQLError({...errorOptions, errors});\n }\n\n try {\n return parseJSON(body).data;\n } catch (e) {\n throwGraphQLError({...errorOptions, errors: [{message: body}]});\n }\n }\n\n return {\n login: async () => {\n const loginUrl = new URL(customerAccountUrl + '/auth/oauth/authorize');\n\n const state = await generateState();\n const nonce = await generateNonce();\n\n loginUrl.searchParams.set('client_id', customerAccountId);\n loginUrl.searchParams.set('scope', 'openid email');\n loginUrl.searchParams.append('response_type', 'code');\n loginUrl.searchParams.append('redirect_uri', origin + '/authorize');\n loginUrl.searchParams.set(\n 'scope',\n 'openid email https://api.customers.com/auth/customer.graphql',\n );\n loginUrl.searchParams.append('state', state);\n loginUrl.searchParams.append('nonce', nonce);\n\n const verifier = await generateCodeVerifier();\n const challenge = await generateCodeChallenge(verifier);\n\n session.set('code-verifier', verifier);\n session.set('state', state);\n session.set('nonce', nonce);\n\n loginUrl.searchParams.append('code_challenge', challenge);\n loginUrl.searchParams.append('code_challenge_method', 'S256');\n\n return redirect(loginUrl.toString(), {\n headers: {\n 'Set-Cookie': await session.commit(),\n },\n });\n },\n logout: async () => {\n const idToken = session.get('id_token');\n\n clearSession(session);\n\n return redirect(\n `${customerAccountUrl}/auth/logout?id_token_hint=${idToken}`,\n {\n status: 302,\n\n headers: {\n 'Set-Cookie': await session.commit(),\n },\n },\n );\n },\n isLoggedIn: async () => {\n const expiresAt = session.get('expires_at');\n\n if (!session.get('customer_access_token') || !expiresAt) return false;\n\n const startTime = new Date().getTime();\n\n try {\n await checkExpires({\n locks,\n expiresAt,\n session,\n customerAccountId,\n customerAccountUrl,\n origin,\n });\n\n logSubRequestEvent?.(' check expires', startTime);\n } catch {\n return false;\n }\n\n return true;\n },\n mutate(mutation, options) {\n mutation = minifyQuery(mutation);\n assertMutation(mutation, 'customer.mutate');\n\n return fetchCustomerAPI({query: mutation, type: 'mutation', ...options});\n },\n query(query, options) {\n query = minifyQuery(query);\n assertQuery(query, 'customer.query');\n\n return fetchCustomerAPI({query, type: 'query', ...options});\n },\n authorize: async (redirectPath = '/') => {\n const code = url.searchParams.get('code');\n const state = url.searchParams.get('state');\n\n if (!code || !state) {\n clearSession(session);\n throw new BadRequest(\n 'Unauthorized',\n 'No code or state parameter found in the redirect URL.',\n );\n }\n\n if (session.get('state') !== state) {\n clearSession(session);\n throw new BadRequest(\n 'Unauthorized',\n 'The session state does not match the state parameter. Make sure that the session is configured correctly and passed to `createCustomerClient`.',\n );\n }\n\n const clientId = customerAccountId;\n const body = new URLSearchParams();\n\n body.append('grant_type', 'authorization_code');\n body.append('client_id', clientId);\n body.append('redirect_uri', origin + '/authorize');\n body.append('code', code);\n\n // Public Client\n const codeVerifier = session.get('code-verifier');\n\n if (!codeVerifier)\n throw new BadRequest(\n 'Unauthorized',\n 'No code verifier found in the session. Make sure that the session is configured correctly and passed to `createCustomerClient`.',\n );\n\n body.append('code_verifier', codeVerifier);\n\n const headers = {\n 'content-type': 'application/x-www-form-urlencoded',\n 'User-Agent': USER_AGENT,\n Origin: origin,\n };\n\n const response = await fetch(`${customerAccountUrl}/auth/oauth/token`, {\n method: 'POST',\n headers,\n body,\n });\n\n if (!response.ok) {\n throw new Response(await response.text(), {\n status: response.status,\n headers: {\n 'Content-Type': 'text/html; charset=utf-8',\n },\n });\n }\n\n const {access_token, expires_in, id_token, refresh_token} =\n await response.json();\n\n const sessionNonce = session.get('nonce');\n const responseNonce = await getNonce(id_token);\n\n if (sessionNonce !== responseNonce) {\n throw new BadRequest(\n 'Unauthorized',\n `Returned nonce does not match: ${sessionNonce} !== ${responseNonce}`,\n );\n }\n\n session.set('customer_authorization_code_token', access_token);\n session.set(\n 'expires_at',\n new Date(new Date().getTime() + (expires_in! - 120) * 1000).getTime() +\n '',\n );\n session.set('id_token', id_token);\n session.set('refresh_token', refresh_token);\n\n const customerAccessToken = await exchangeAccessToken(\n session,\n customerAccountId,\n customerAccountUrl,\n origin,\n );\n\n session.set('customer_access_token', customerAccessToken);\n\n return redirect(redirectPath, {\n headers: {\n 'Set-Cookie': await session.commit(),\n },\n });\n },\n };\n}" + }, + "CustomerClientOptions": { + "filePath": "/customer/customer.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "CustomerClientOptions", + "value": "{\n /** The client requires a session to persist the auth and refresh token. By default Hydrogen ships with cookie session storage, but you can use [another session storage](https://remix.run/docs/en/main/utils/sessions) implementation. */\n session: HydrogenSession;\n /** Unique UUID prefixed with `shp_` associated with the application, this should be visible in the customer account api settings in the Hydrogen admin channel. */\n customerAccountId: string;\n /** The account URL associated with the application, this should be visible in the customer account api settings in the Hydrogen admin channel. */\n customerAccountUrl: string;\n /** Override the version of the API */\n customerApiVersion?: string;\n /** The object for the current Request. It should be provided by your platform. */\n request: CrossRuntimeRequest;\n /** The waitUntil function is used to keep the current request/response lifecycle alive even after a response has been sent. It should be provided by your platform. */\n waitUntil?: ExecutionContext['waitUntil'];\n}", + "description": "", + "members": [ + { + "filePath": "/customer/customer.ts", + "syntaxKind": "PropertySignature", + "name": "session", + "value": "HydrogenSession", + "description": "The client requires a session to persist the auth and refresh token. By default Hydrogen ships with cookie session storage, but you can use [another session storage](https://remix.run/docs/en/main/utils/sessions) implementation." + }, + { + "filePath": "/customer/customer.ts", + "syntaxKind": "PropertySignature", + "name": "customerAccountId", + "value": "string", + "description": "Unique UUID prefixed with `shp_` associated with the application, this should be visible in the customer account api settings in the Hydrogen admin channel." + }, + { + "filePath": "/customer/customer.ts", + "syntaxKind": "PropertySignature", + "name": "customerAccountUrl", + "value": "string", + "description": "The account URL associated with the application, this should be visible in the customer account api settings in the Hydrogen admin channel." + }, + { + "filePath": "/customer/customer.ts", + "syntaxKind": "PropertySignature", + "name": "customerApiVersion", + "value": "string", + "description": "Override the version of the API", + "isOptional": true + }, + { + "filePath": "/customer/customer.ts", + "syntaxKind": "PropertySignature", + "name": "request", + "value": "CrossRuntimeRequest", + "description": "The object for the current Request. It should be provided by your platform." + }, + { + "filePath": "/customer/customer.ts", + "syntaxKind": "PropertySignature", + "name": "waitUntil", + "value": "ExecutionContext", + "description": "The waitUntil function is used to keep the current request/response lifecycle alive even after a response has been sent. It should be provided by your platform.", + "isOptional": true + } + ] + }, + "HydrogenSession": { + "filePath": "/customer/auth.helpers.ts", + "name": "HydrogenSession", + "description": "", + "members": [ + { + "filePath": "/customer/auth.helpers.ts", + "syntaxKind": "PropertySignature", + "name": "get", + "value": "(key: string) => string", + "description": "" + }, + { + "filePath": "/customer/auth.helpers.ts", + "syntaxKind": "PropertySignature", + "name": "set", + "value": "(key: string, value: string) => void", + "description": "" + }, + { + "filePath": "/customer/auth.helpers.ts", + "syntaxKind": "PropertySignature", + "name": "unset", + "value": "(key: string) => void", + "description": "" + }, + { + "filePath": "/customer/auth.helpers.ts", + "syntaxKind": "PropertySignature", + "name": "commit", + "value": "() => Promise", + "description": "" + } + ], + "value": "export interface HydrogenSession {\n get: (key: string) => string | undefined;\n set: (key: string, value: string) => void;\n unset: (key: string) => void;\n commit: () => Promise;\n}" + }, + "CrossRuntimeRequest": { + "filePath": "/utils/request.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "CrossRuntimeRequest", + "value": "{\n url?: string;\n method?: string;\n headers: {\n get?: (key: string) => string | null | undefined;\n [key: string]: any;\n };\n}", + "description": "", + "members": [ + { + "filePath": "/utils/request.ts", + "syntaxKind": "PropertySignature", + "name": "url", + "value": "string", + "description": "", + "isOptional": true + }, + { + "filePath": "/utils/request.ts", + "syntaxKind": "PropertySignature", + "name": "method", + "value": "string", + "description": "", + "isOptional": true + }, + { + "filePath": "/utils/request.ts", + "syntaxKind": "PropertySignature", + "name": "headers", + "value": "{ [key: string]: any; get?: (key: string) => string; }", + "description": "" + } + ] + }, + "CustomerClient": { + "filePath": "/customer/customer.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "CustomerClient", + "value": "{\n /** Start the OAuth login flow. This function should be called and returned from a Remix action. It redirects the user to a login domain. */\n login: () => Promise;\n /** On successful login, the user is redirect back to your app. This function validates the OAuth response and exchanges the authorization code for an access token and refresh token. It also persists the tokens on your session. This function should be called and returned from the Remix loader configured as the redirect URI within the Customer Account API settings. */\n authorize: (redirectPath?: string) => Promise;\n /** Returns if the user is logged in. It also checks if the access token is expired and refreshes it if needed. */\n isLoggedIn: () => Promise;\n /** Logout the user by clearing the session and redirecting to the login domain. It should be called and returned from a Remix action. */\n logout: () => Promise;\n /** Execute a GraphQL query against the Customer Account API. Usually you should first check if the user is logged in before querying the API. */\n query: (\n query: RawGqlString,\n options?: {variables: Record},\n ) => Promise;\n /** Execute a GraphQL mutation against the Customer Account API. Usually you should first check if the user is logged in before querying the API. */\n mutate: (\n mutation: RawGqlString,\n options?: {variables: Record},\n ) => Promise;\n}", + "description": "", + "members": [ + { + "filePath": "/customer/customer.ts", + "syntaxKind": "PropertySignature", + "name": "login", + "value": "() => Promise", + "description": "Start the OAuth login flow. This function should be called and returned from a Remix action. It redirects the user to a login domain." + }, + { + "filePath": "/customer/customer.ts", + "syntaxKind": "PropertySignature", + "name": "authorize", + "value": "(redirectPath?: string) => Promise", + "description": "On successful login, the user is redirect back to your app. This function validates the OAuth response and exchanges the authorization code for an access token and refresh token. It also persists the tokens on your session. This function should be called and returned from the Remix loader configured as the redirect URI within the Customer Account API settings." + }, + { + "filePath": "/customer/customer.ts", + "syntaxKind": "PropertySignature", + "name": "isLoggedIn", + "value": "() => Promise", + "description": "Returns if the user is logged in. It also checks if the access token is expired and refreshes it if needed." + }, + { + "filePath": "/customer/customer.ts", + "syntaxKind": "PropertySignature", + "name": "logout", + "value": "() => Promise", + "description": "Logout the user by clearing the session and redirecting to the login domain. It should be called and returned from a Remix action." + }, + { + "filePath": "/customer/customer.ts", + "syntaxKind": "PropertySignature", + "name": "query", + "value": "(query: RawGqlString, options?: { variables: Record; }) => Promise", + "description": "Execute a GraphQL query against the Customer Account API. Usually you should first check if the user is logged in before querying the API." + }, + { + "filePath": "/customer/customer.ts", + "syntaxKind": "PropertySignature", + "name": "mutate", + "value": "(mutation: RawGqlString, options?: { variables: Record; }) => Promise", + "description": "Execute a GraphQL mutation against the Customer Account API. Usually you should first check if the user is logged in before querying the API." + } + ] + } + } + } + ] + }, { "name": "OptimisticInput", "category": "components", @@ -9102,7 +9324,7 @@ "name": "", "value": "" }, - "value": "export function useOptimisticData(identifier: string) {\n const fetchers = useFetchers();\n const data: Record = {};\n\n for (const fetcher of fetchers) {\n const formData = fetcher.submission?.formData;\n if (formData && formData.get('optimistic-identifier') === identifier) {\n try {\n if (formData.has('optimistic-data')) {\n const dataInForm: unknown = JSON.parse(\n String(formData.get('optimistic-data')),\n );\n Object.assign(data, dataInForm);\n }\n } catch {\n // do nothing\n }\n }\n }\n return data as T;\n}" + "value": "export function useOptimisticData(identifier: string) {\n const fetchers = useFetchers();\n const data: Record = {};\n\n for (const {formData} of fetchers) {\n if (formData?.get('optimistic-identifier') === identifier) {\n try {\n if (formData.has('optimistic-data')) {\n const dataInForm: unknown = JSON.parse(\n String(formData.get('optimistic-data')),\n );\n Object.assign(data, dataInForm);\n }\n } catch {\n // do nothing\n }\n }\n }\n return data as T;\n}" } } } @@ -9132,7 +9354,7 @@ }, { "title": "TypeScript", - "code": "import {json, type LoaderArgs} from '@shopify/remix-oxygen';\nimport {Pagination, getPaginationVariables} from '@shopify/hydrogen';\nimport {useLoaderData, Link} from '@remix-run/react';\nimport {ProductConnection} from '@shopify/hydrogen/storefront-api-types';\n\nexport async function loader({request, context: {storefront}}: LoaderArgs) {\n const variables = getPaginationVariables(request, {pageBy: 8});\n\n const data = await storefront.query<{products: ProductConnection}>(\n ALL_PRODUCTS_QUERY,\n {\n variables,\n },\n );\n\n return json({products: data.products});\n}\n\nexport default function List() {\n const {products} = useLoaderData<typeof loader>();\n\n return (\n <Pagination connection={products}>\n {({nodes, NextLink, PreviousLink}) => (\n <>\n <PreviousLink>Previous</PreviousLink>\n <div>\n {nodes.map((product) => (\n <Link key={product.id} to={`/products/${product.handle}`}>\n {product.title}\n </Link>\n ))}\n </div>\n <NextLink>Next</NextLink>\n </>\n )}\n </Pagination>\n );\n}\n\nconst ALL_PRODUCTS_QUERY = `#graphql\n query AllProducts(\n $country: CountryCode\n $language: LanguageCode\n $first: Int\n $last: Int\n $startCursor: String\n $endCursor: String\n ) @inContext(country: $country, language: $language) {\n products(first: $first, last: $last, before: $startCursor, after: $endCursor) {\n nodes { id\n title\n handle\n }\n pageInfo {\n hasPreviousPage\n hasNextPage\n startCursor\n endCursor\n }\n }\n }\n`;\n", + "code": "import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';\nimport {Pagination, getPaginationVariables} from '@shopify/hydrogen';\nimport {useLoaderData, Link} from '@remix-run/react';\nimport {ProductConnection} from '@shopify/hydrogen/storefront-api-types';\n\nexport async function loader({\n request,\n context: {storefront},\n}: LoaderFunctionArgs) {\n const variables = getPaginationVariables(request, {pageBy: 8});\n\n const data = await storefront.query<{products: ProductConnection}>(\n ALL_PRODUCTS_QUERY,\n {\n variables,\n },\n );\n\n return json({products: data.products});\n}\n\nexport default function List() {\n const {products} = useLoaderData<typeof loader>();\n\n return (\n <Pagination connection={products}>\n {({nodes, NextLink, PreviousLink}) => (\n <>\n <PreviousLink>Previous</PreviousLink>\n <div>\n {nodes.map((product) => (\n <Link key={product.id} to={`/products/${product.handle}`}>\n {product.title}\n </Link>\n ))}\n </div>\n <NextLink>Next</NextLink>\n </>\n )}\n </Pagination>\n );\n}\n\nconst ALL_PRODUCTS_QUERY = `#graphql\n query AllProducts(\n $country: CountryCode\n $language: LanguageCode\n $first: Int\n $last: Int\n $startCursor: String\n $endCursor: String\n ) @inContext(country: $country, language: $language) {\n products(first: $first, last: $last, before: $startCursor, after: $endCursor) {\n nodes { id\n title\n handle\n }\n pageInfo {\n hasPreviousPage\n hasNextPage\n startCursor\n endCursor\n }\n }\n }\n`;\n", "language": "tsx" } ], @@ -9281,7 +9503,7 @@ "url": "/docs/api/hydrogen/2023-10/components/pagination" } ], - "description": "> Caution:\n> This component is in an unstable pre-release state and may have breaking changes in a future release.\n\nThe `getPaginationVariables` function is used with the [``](/docs/api/hydrogen/components/pagnination) component to generate the variables needed to fetch paginated data from the Storefront API. The returned variables should be used within your storefront GraphQL query.", + "description": "The `getPaginationVariables` function is used with the [``](/docs/api/hydrogen/components/pagnination) component to generate the variables needed to fetch paginated data from the Storefront API. The returned variables should be used within your storefront GraphQL query.", "type": "utility", "defaultExample": { "description": "I am the default example", @@ -9294,7 +9516,7 @@ }, { "title": "TypeScript", - "code": "import {json, type LoaderArgs} from '@shopify/remix-oxygen';\nimport {Pagination, getPaginationVariables} from '@shopify/hydrogen';\nimport {useLoaderData, Link} from '@remix-run/react';\nimport {ProductConnection} from '@shopify/hydrogen/storefront-api-types';\n\nexport async function loader({request, context: {storefront}}: LoaderArgs) {\n const variables = getPaginationVariables(request, {pageBy: 8});\n\n const data = await storefront.query<{products: ProductConnection}>(\n ALL_PRODUCTS_QUERY,\n {\n variables,\n },\n );\n\n return json({products: data.products});\n}\n\nexport default function List() {\n const {products} = useLoaderData<typeof loader>();\n\n return (\n <Pagination connection={products}>\n {({nodes, NextLink, PreviousLink}) => (\n <>\n <PreviousLink>Previous</PreviousLink>\n <div>\n {nodes.map((product) => (\n <Link key={product.id} to={`/products/${product.handle}`}>\n {product.title}\n </Link>\n ))}\n </div>\n <NextLink>Next</NextLink>\n </>\n )}\n </Pagination>\n );\n}\n\nconst ALL_PRODUCTS_QUERY = `#graphql\n query AllProducts(\n $country: CountryCode\n $language: LanguageCode\n $first: Int\n $last: Int\n $startCursor: String\n $endCursor: String\n ) @inContext(country: $country, language: $language) {\n products(first: $first, last: $last, before: $startCursor, after: $endCursor) {\n nodes { id\n title\n handle\n }\n pageInfo {\n hasPreviousPage\n hasNextPage\n startCursor\n endCursor\n }\n }\n }\n`;\n", + "code": "import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';\nimport {Pagination, getPaginationVariables} from '@shopify/hydrogen';\nimport {useLoaderData, Link} from '@remix-run/react';\nimport {ProductConnection} from '@shopify/hydrogen/storefront-api-types';\n\nexport async function loader({\n request,\n context: {storefront},\n}: LoaderFunctionArgs) {\n const variables = getPaginationVariables(request, {pageBy: 8});\n\n const data = await storefront.query<{products: ProductConnection}>(\n ALL_PRODUCTS_QUERY,\n {\n variables,\n },\n );\n\n return json({products: data.products});\n}\n\nexport default function List() {\n const {products} = useLoaderData<typeof loader>();\n\n return (\n <Pagination connection={products}>\n {({nodes, NextLink, PreviousLink}) => (\n <>\n <PreviousLink>Previous</PreviousLink>\n <div>\n {nodes.map((product) => (\n <Link key={product.id} to={`/products/${product.handle}`}>\n {product.title}\n </Link>\n ))}\n </div>\n <NextLink>Next</NextLink>\n </>\n )}\n </Pagination>\n );\n}\n\nconst ALL_PRODUCTS_QUERY = `#graphql\n query AllProducts(\n $country: CountryCode\n $language: LanguageCode\n $first: Int\n $last: Int\n $startCursor: String\n $endCursor: String\n ) @inContext(country: $country, language: $language) {\n products(first: $first, last: $last, before: $startCursor, after: $endCursor) {\n nodes { id\n title\n handle\n }\n pageInfo {\n hasPreviousPage\n hasNextPage\n startCursor\n endCursor\n }\n }\n }\n`;\n", "language": "tsx" } ], @@ -9525,7 +9747,7 @@ }, { "title": "TypeScript", - "code": "import {getSelectedProductOptions} from '@shopify/hydrogen';\nimport {json, type LoaderArgs} from '@shopify/remix-oxygen';\n\nexport async function loader({request, params, context}: LoaderArgs) {\n const selectedOptions = getSelectedProductOptions(request);\n\n const {product} = await context.storefront.query(PRODUCT_QUERY, {\n variables: {\n handle: params.productHandle,\n selectedOptions,\n },\n });\n\n return json({product});\n}\n\nconst PRODUCT_QUERY = `#graphql\n query Product($handle: String!, $selectedOptions: [SelectedOptionInput!]!) {\n product(handle: $handle) {\n title\n description\n options {\n name\n values \n }\n selectedVariant: variantBySelectedOptions(selectedOptions: $selectedOptions) {\n ...ProductVariantFragment\n }\n }\n }\n`;\n", + "code": "import {getSelectedProductOptions} from '@shopify/hydrogen';\nimport {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';\n\nexport async function loader({request, params, context}: LoaderFunctionArgs) {\n const selectedOptions = getSelectedProductOptions(request);\n\n const {product} = await context.storefront.query(PRODUCT_QUERY, {\n variables: {\n handle: params.productHandle,\n selectedOptions,\n },\n });\n\n return json({product});\n}\n\nconst PRODUCT_QUERY = `#graphql\n query Product($handle: String!, $selectedOptions: [SelectedOptionInput!]!) {\n product(handle: $handle) {\n title\n description\n options {\n name\n values \n }\n selectedVariant: variantBySelectedOptions(selectedOptions: $selectedOptions) {\n ...ProductVariantFragment\n }\n }\n }\n`;\n", "language": "tsx" } ], @@ -9580,7 +9802,7 @@ }, { "title": "TypeScript", - "code": "import {graphiqlLoader} from '@shopify/hydrogen';\nimport {redirect, type LoaderArgs} from '@shopify/remix-oxygen';\n\nexport async function loader(args: LoaderArgs) {\n if (process.env.NODE_ENV === 'development') {\n return graphiqlLoader(args);\n }\n\n return redirect('/');\n}\n", + "code": "import {graphiqlLoader} from '@shopify/hydrogen';\nimport {redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';\n\nexport async function loader(args: LoaderFunctionArgs) {\n if (process.env.NODE_ENV === 'development') {\n return graphiqlLoader(args);\n }\n\n return redirect('/');\n}\n", "language": "ts" } ], @@ -9611,7 +9833,7 @@ "name": "Promise", "value": "Promise" }, - "value": "type GraphiQLLoader = (args: LoaderArgs) => Promise;" + "value": "type GraphiQLLoader = (args: LoaderFunctionArgs) => Promise;" } } } @@ -10083,12 +10305,12 @@ "tabs": [ { "title": "JavaScript", - "code": "// In your app's `server.ts` file:\nimport * as remixBuild from '@remix-run/dev/server-build';\nimport {createWithCache, CacheLong} from '@shopify/hydrogen';\n// Use another `createRequestHandler` if deploying off oxygen\nimport {createRequestHandler} from '@shopify/remix-oxygen';\n\nexport default {\n async fetch(request, env, executionContext) {\n const cache = await caches.open('my-cms');\n const withCache = createWithCache({\n cache,\n waitUntil: executionContext.waitUntil,\n });\n\n // Create a custom utility to query a third-party API:\n const fetchMyCMS = (query) => {\n // Prefix the cache key and make it unique based on arguments.\n return withCache(['my-cms', query], CacheLong(), async () => {\n return await (\n await fetch('my-cms.com/api', {\n method: 'POST',\n body: query,\n })\n ).json();\n });\n };\n\n const handleRequest = createRequestHandler({\n build: remixBuild,\n mode: process.env.NODE_ENV,\n getLoadContext: () => ({\n /* ... */\n fetchMyCMS,\n }),\n });\n\n return handleRequest(request);\n },\n};\n", + "code": "// In your app's `server.ts` file:\nimport * as remixBuild from '@remix-run/dev/server-build';\nimport {createWithCache, CacheLong} from '@shopify/hydrogen';\n// Use another `createRequestHandler` if deploying off oxygen\nimport {createRequestHandler} from '@shopify/remix-oxygen';\n\nexport default {\n async fetch(request, env, executionContext) {\n const cache = await caches.open('my-cms');\n const withCache = createWithCache({\n cache,\n waitUntil: executionContext.waitUntil.bind(executionContext),\n request,\n });\n\n // Create a custom utility to query a third-party API:\n const fetchMyCMS = (query) => {\n // Prefix the cache key and make it unique based on arguments.\n return withCache(['my-cms', query], CacheLong(), async () => {\n return await (\n await fetch('my-cms.com/api', {\n method: 'POST',\n body: query,\n })\n ).json();\n });\n };\n\n const handleRequest = createRequestHandler({\n build: remixBuild,\n mode: process.env.NODE_ENV,\n getLoadContext: () => ({\n /* ... */\n fetchMyCMS,\n }),\n });\n\n return handleRequest(request);\n },\n};\n", "language": "js" }, { "title": "TypeScript", - "code": "// In your app's `server.ts` file:\nimport * as remixBuild from '@remix-run/dev/server-build';\nimport {createWithCache, CacheLong} from '@shopify/hydrogen';\n// Use another `createRequestHandler` if deploying off oxygen\nimport {createRequestHandler} from '@shopify/remix-oxygen';\n\nexport default {\n async fetch(\n request: Request,\n env: Record<string, string>,\n executionContext: ExecutionContext,\n ) {\n const cache = await caches.open('my-cms');\n const withCache = createWithCache({\n cache,\n waitUntil: executionContext.waitUntil,\n });\n\n // Create a custom utility to query a third-party API:\n const fetchMyCMS = (query: string) => {\n // Prefix the cache key and make it unique based on arguments.\n return withCache(['my-cms', query], CacheLong(), async () => {\n return await (\n await fetch('my-cms.com/api', {\n method: 'POST',\n body: query,\n })\n ).json();\n });\n };\n\n const handleRequest = createRequestHandler({\n build: remixBuild,\n mode: process.env.NODE_ENV,\n getLoadContext: () => ({\n // Make sure to update remix.env.d.ts to include `fetchMyCMS`\n fetchMyCMS,\n }),\n });\n\n return handleRequest(request);\n },\n};\n", + "code": "// In your app's `server.ts` file:\nimport * as remixBuild from '@remix-run/dev/server-build';\nimport {createWithCache, CacheLong} from '@shopify/hydrogen';\n// Use another `createRequestHandler` if deploying off oxygen\nimport {createRequestHandler} from '@shopify/remix-oxygen';\n\nexport default {\n async fetch(\n request: Request,\n env: Record<string, string>,\n executionContext: ExecutionContext,\n ) {\n const cache = await caches.open('my-cms');\n const withCache = createWithCache({\n cache,\n waitUntil: executionContext.waitUntil.bind(executionContext),\n request,\n });\n\n // Create a custom utility to query a third-party API:\n const fetchMyCMS = (query: string) => {\n // Prefix the cache key and make it unique based on arguments.\n return withCache(['my-cms', query], CacheLong(), async () => {\n return await (\n await fetch('my-cms.com/api', {\n method: 'POST',\n body: query,\n })\n ).json();\n });\n };\n\n const handleRequest = createRequestHandler({\n build: remixBuild,\n mode: process.env.NODE_ENV,\n getLoadContext: () => ({\n // Make sure to update remix.env.d.ts to include `fetchMyCMS`\n fetchMyCMS,\n }),\n });\n\n return handleRequest(request);\n },\n};\n", "language": "ts" } ], @@ -10104,10 +10326,10 @@ "CreateWithCacheGeneratedType": { "filePath": "/with-cache.ts", "name": "CreateWithCacheGeneratedType", - "description": "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.", + "description": "", "params": [ { - "name": "options", + "name": "input1", "description": "", "value": "CreateWithCacheOptions", "filePath": "/with-cache.ts" @@ -10119,13 +10341,13 @@ "name": "CreateWithCacheReturn", "value": "CreateWithCacheReturn" }, - "value": "export function createWithCache(\n options: CreateWithCacheOptions,\n): CreateWithCacheReturn {\n const {cache, waitUntil} = options;\n return function withCache(\n cacheKey: CacheKey,\n strategy: CachingStrategy,\n actionFn: () => T | Promise,\n ) {\n return runWithCache(cacheKey, actionFn, {\n strategy,\n cacheInstance: cache,\n waitUntil,\n debugInfo: {},\n });\n };\n}" + "value": "export function createWithCache({\n cache,\n waitUntil,\n request,\n}: CreateWithCacheOptions): CreateWithCacheReturn {\n return function withCache(\n cacheKey: CacheKey,\n strategy: CachingStrategy,\n actionFn: () => T | Promise,\n ) {\n return runWithCache(cacheKey, actionFn, {\n strategy,\n cacheInstance: cache,\n waitUntil,\n debugInfo: getDebugHeaders(request),\n });\n };\n}" }, "CreateWithCacheOptions": { "filePath": "/with-cache.ts", "syntaxKind": "TypeAliasDeclaration", "name": "CreateWithCacheOptions", - "value": "{\n /** An instance that implements the [Cache API](https://developer.mozilla.org/en-US/docs/Web/API/Cache) */\n cache: Cache;\n /** The `waitUntil` function is used to keep the current request/response lifecycle alive even after a response has been sent. It should be provided by your platform. */\n waitUntil: ExecutionContext['waitUntil'];\n}", + "value": "{\n /** An instance that implements the [Cache API](https://developer.mozilla.org/en-US/docs/Web/API/Cache) */\n cache: Cache;\n /** The `waitUntil` function is used to keep the current request/response lifecycle alive even after a response has been sent. It should be provided by your platform. */\n waitUntil: ExecutionContext['waitUntil'];\n /** The `request` object is used to access certain headers for debugging */\n request?: CrossRuntimeRequest;\n}", "description": "", "members": [ { @@ -10141,6 +10363,46 @@ "name": "waitUntil", "value": "ExecutionContext", "description": "The `waitUntil` function is used to keep the current request/response lifecycle alive even after a response has been sent. It should be provided by your platform." + }, + { + "filePath": "/with-cache.ts", + "syntaxKind": "PropertySignature", + "name": "request", + "value": "CrossRuntimeRequest", + "description": "The `request` object is used to access certain headers for debugging", + "isOptional": true + } + ] + }, + "CrossRuntimeRequest": { + "filePath": "/utils/request.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "CrossRuntimeRequest", + "value": "{\n url?: string;\n method?: string;\n headers: {\n get?: (key: string) => string | null | undefined;\n [key: string]: any;\n };\n}", + "description": "", + "members": [ + { + "filePath": "/utils/request.ts", + "syntaxKind": "PropertySignature", + "name": "url", + "value": "string", + "description": "", + "isOptional": true + }, + { + "filePath": "/utils/request.ts", + "syntaxKind": "PropertySignature", + "name": "method", + "value": "string", + "description": "", + "isOptional": true + }, + { + "filePath": "/utils/request.ts", + "syntaxKind": "PropertySignature", + "name": "headers", + "value": "{ [key: string]: any; get?: (key: string) => string; }", + "description": "" } ] }, diff --git a/packages/hydrogen/src/customer/BadRequest.ts b/packages/hydrogen/src/customer/BadRequest.ts new file mode 100644 index 0000000000..33733c7e60 --- /dev/null +++ b/packages/hydrogen/src/customer/BadRequest.ts @@ -0,0 +1,11 @@ +export class BadRequest extends Response { + constructor(message?: string, helpMessage?: string) { + // A lot of things can go wrong when configuring the customer account api + // oauth flow. In dev mode, log a helper message. + if (helpMessage && process.env.NODE_ENV === 'development') { + console.error('Customer Account API Error: ' + helpMessage); + } + + super(`Bad request: ${message}`, {status: 400}); + } +} diff --git a/packages/hydrogen/src/customer/auth.helpers.test.ts b/packages/hydrogen/src/customer/auth.helpers.test.ts new file mode 100644 index 0000000000..bae154590b --- /dev/null +++ b/packages/hydrogen/src/customer/auth.helpers.test.ts @@ -0,0 +1,323 @@ +import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; +import { + HydrogenSession, + checkExpires, + clearSession, + refreshToken, +} from './auth.helpers'; + +vi.mock('./BadRequest', () => { + return { + BadRequest: class BadRequest { + message: string; + constructor(message?: string, helpMessage?: string) { + this.message = `${message} ${helpMessage}`; + } + }, + }; +}); + +vi.stubGlobal( + 'Response', + class Response { + message; + constructor(body: any, options: any) { + this.message = body; + } + }, +); + +const fetch = (global.fetch = vi.fn() as any); + +function createFetchResponse(data: T, options: {ok: boolean}) { + return { + json: () => new Promise((resolve) => resolve(data)), + text: async () => JSON.stringify(data), + ok: options.ok, + }; +} + +let session: HydrogenSession; + +describe('auth.helpers', () => { + describe('refreshToken', () => { + beforeEach(() => { + session = { + commit: vi.fn(() => new Promise((resolve) => resolve('cookie'))), + get: vi.fn(), + set: vi.fn(), + unset: vi.fn(), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("Throws BadRequest when there's no refresh token in the session", async () => { + async function run() { + await refreshToken({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'customerAccountUrl', + origin: 'https://localhost', + }); + } + + await expect(run).rejects.toThrowError( + 'Unauthorized No refresh_token in the session. Make sure your session is configured correctly and passed to `createCustomerClient`.', + ); + }); + + it('Throws Unauthorized when refresh token fails', async () => { + (session.get as any).mockResolvedValueOnce('refresh_token'); + + fetch.mockResolvedValue(createFetchResponse('Unauthorized', {ok: false})); + + async function run() { + await refreshToken({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'customerAccountUrl', + origin: 'https://localhost', + }); + } + + await expect(run).rejects.toThrowError('Unauthorized'); + }); + + it('Throws when there is no valid authorization code in the session', async () => { + (session.get as any).mockResolvedValueOnce('refresh_token'); + + fetch.mockResolvedValue( + createFetchResponse( + { + access_token: '', + expires_in: '', + id_token: '', + refresh_token: '', + }, + {ok: true}, + ), + ); + + async function run() { + await refreshToken({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'customerAccountUrl', + origin: 'https://localhost', + }); + } + + await expect(run).rejects.toThrowError( + 'Unauthorized No access token found in the session. Make sure your session is configured correctly and passed to `createCustomerClient`', + ); + }); + + it('Refreshes the token', async () => { + (session.get as any).mockResolvedValue('value'); + + fetch.mockResolvedValue( + createFetchResponse( + { + access_token: 'access_token', + expires_in: '', + id_token: 'id_token', + refresh_token: 'refresh_token', + }, + {ok: true}, + ), + ); + + await refreshToken({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'customerAccountUrl', + origin: 'https://localhost', + }); + + expect(session.set).toHaveBeenNthCalledWith( + 1, + 'customer_authorization_code_token', + 'access_token', + ); + expect(session.set).toHaveBeenNthCalledWith( + 2, + 'expires_at', + expect.anything(), + ); + expect(session.set).toHaveBeenNthCalledWith(3, 'id_token', 'id_token'); + expect(session.set).toHaveBeenNthCalledWith( + 4, + 'refresh_token', + 'refresh_token', + ); + expect(session.set).toHaveBeenNthCalledWith( + 5, + 'customer_access_token', + 'access_token', + ); + }); + }); + + describe('clearSession', () => { + beforeEach(() => { + session = { + commit: vi.fn(() => new Promise((resolve) => resolve('cookie'))), + get: vi.fn(), + set: vi.fn(), + unset: vi.fn(), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('Clears the session', async () => { + clearSession(session); + expect(session.unset).toHaveBeenCalledWith('code-verifier'); + expect(session.unset).toHaveBeenCalledWith( + 'customer_authorization_code_token', + ); + expect(session.unset).toHaveBeenCalledWith('expires_at'); + expect(session.unset).toHaveBeenCalledWith('id_token'); + expect(session.unset).toHaveBeenCalledWith('refresh_token'); + expect(session.unset).toHaveBeenCalledWith('customer_access_token'); + expect(session.unset).toHaveBeenCalledWith('state'); + expect(session.unset).toHaveBeenCalledWith('nonce'); + }); + }); + + describe('checkExpires', () => { + beforeEach(() => { + session = { + commit: vi.fn(() => new Promise((resolve) => resolve('cookie'))), + get: vi.fn(), + set: vi.fn(), + unset: vi.fn(), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("no-ops if the session isn't expired", async () => { + async function run() { + await checkExpires({ + locks: {}, + expiresAt: new Date().getTime() + 10000 + '', + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'customerAccountUrl', + origin: 'https://localhost', + }); + } + + expect(await run()).toBeUndefined(); + }); + + it('Refreshes the token', async () => { + (session.get as any).mockResolvedValue('value'); + + fetch.mockResolvedValue( + createFetchResponse( + { + access_token: 'access_token', + expires_in: '', + id_token: 'id_token', + refresh_token: 'refresh_token', + }, + {ok: true}, + ), + ); + + await checkExpires({ + locks: {}, + expiresAt: '100', + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'customerAccountUrl', + origin: 'https://localhost', + }); + + expect(session.set).toHaveBeenNthCalledWith( + 1, + 'customer_authorization_code_token', + 'access_token', + ); + expect(session.set).toHaveBeenNthCalledWith( + 2, + 'expires_at', + expect.anything(), + ); + expect(session.set).toHaveBeenNthCalledWith(3, 'id_token', 'id_token'); + expect(session.set).toHaveBeenNthCalledWith( + 4, + 'refresh_token', + 'refresh_token', + ); + expect(session.set).toHaveBeenNthCalledWith( + 5, + 'customer_access_token', + 'access_token', + ); + }); + + it('does not refresh the token when a refresh is already in process', async () => { + (session.get as any).mockResolvedValue('value'); + + fetch.mockResolvedValue( + createFetchResponse( + { + access_token: 'access_token', + expires_in: '', + id_token: 'id_token', + refresh_token: 'refresh_token', + }, + {ok: true}, + ), + ); + + await checkExpires({ + locks: { + // mock an existing refresh promise + refresh: Promise.resolve(), + }, + expiresAt: '100', + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'customerAccountUrl', + origin: 'https://localhost', + }); + + expect(session.set).not.toHaveBeenNthCalledWith( + 1, + 'customer_authorization_code_token', + 'access_token', + ); + expect(session.set).not.toHaveBeenNthCalledWith( + 2, + 'expires_at', + expect.anything(), + ); + expect(session.set).not.toHaveBeenNthCalledWith( + 3, + 'id_token', + 'id_token', + ); + expect(session.set).not.toHaveBeenNthCalledWith( + 4, + 'refresh_token', + 'refresh_token', + ); + expect(session.set).not.toHaveBeenNthCalledWith( + 5, + 'customer_access_token', + 'access_token', + ); + }); + }); +}); diff --git a/packages/hydrogen/src/customer/auth.helpers.ts b/packages/hydrogen/src/customer/auth.helpers.ts new file mode 100644 index 0000000000..4f973e3102 --- /dev/null +++ b/packages/hydrogen/src/customer/auth.helpers.ts @@ -0,0 +1,264 @@ +import {BadRequest} from './BadRequest'; +import {LIB_VERSION} from '../version'; + +export interface Locks { + refresh?: Promise; +} + +export const USER_AGENT = `Shopify Hydrogen ${LIB_VERSION}`; + +const CUSTOMER_API_CLIENT_ID = '30243aa5-17c1-465a-8493-944bcc4e88aa'; + +export interface HydrogenSession { + get: (key: string) => string | undefined; + set: (key: string, value: string) => void; + unset: (key: string) => void; + commit: () => Promise; +} + +export function redirect( + path: string, + options: {status?: number; headers?: {}} = {}, +) { + const headers = options.headers + ? new Headers(options.headers) + : new Headers({}); + headers.set('location', path); + + return new Response(null, {status: options.status || 302, headers}); +} + +export interface AccessTokenResponse { + access_token: string; + expires_in: number; + id_token: string; + refresh_token: string; + error?: string; + error_description?: string; +} + +export async function refreshToken({ + session, + customerAccountId, + customerAccountUrl, + origin, +}: { + session: HydrogenSession; + customerAccountId: string; + customerAccountUrl: string; + origin: string; +}) { + const newBody = new URLSearchParams(); + + const refreshToken = session.get('refresh_token'); + + if (!refreshToken) + throw new BadRequest( + 'Unauthorized', + 'No refresh_token in the session. Make sure your session is configured correctly and passed to `createCustomerClient`.', + ); + + newBody.append('grant_type', 'refresh_token'); + newBody.append('refresh_token', refreshToken); + newBody.append('client_id', customerAccountId); + + const headers = { + 'content-type': 'application/x-www-form-urlencoded', + 'User-Agent': USER_AGENT, + Origin: origin, + }; + + const response = await fetch(`${customerAccountUrl}/auth/oauth/token`, { + method: 'POST', + headers, + body: newBody, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Response(text, { + status: response.status, + headers: { + 'Content-Type': 'text/html; charset=utf-8', + }, + }); + } + + const {access_token, expires_in, id_token, refresh_token} = + await response.json(); + + session.set('customer_authorization_code_token', access_token); + // Store the date in future the token expires, separated by two minutes + session.set( + 'expires_at', + new Date(new Date().getTime() + (expires_in - 120) * 1000).getTime() + '', + ); + session.set('id_token', id_token); + session.set('refresh_token', refresh_token); + + const customerAccessToken = await exchangeAccessToken( + session, + customerAccountId, + customerAccountUrl, + origin, + ); + + session.set('customer_access_token', customerAccessToken); +} + +export function clearSession(session: HydrogenSession): void { + session.unset('code-verifier'); + session.unset('customer_authorization_code_token'); + session.unset('expires_at'); + session.unset('id_token'); + session.unset('refresh_token'); + session.unset('customer_access_token'); + session.unset('state'); + session.unset('nonce'); +} + +export async function checkExpires({ + locks, + expiresAt, + session, + customerAccountId, + customerAccountUrl, + origin, +}: { + locks: Locks; + expiresAt: string; + session: HydrogenSession; + customerAccountId: string; + customerAccountUrl: string; + origin: string; +}) { + if (parseInt(expiresAt, 10) - 1000 < new Date().getTime()) { + try { + // Makes sure that only one refresh request is sent at a time + if (!locks.refresh) + locks.refresh = refreshToken({ + session, + customerAccountId, + customerAccountUrl, + origin, + }); + + await locks.refresh; + delete locks.refresh; + } catch (error) { + clearSession(session); + + if (error && (error as Response).status !== 401) { + throw error; + } else { + throw new BadRequest( + 'Unauthorized', + 'Login before querying the Customer Account API.', + ); + } + } + } +} + +export async function generateCodeVerifier() { + const rando = generateRandomCode(); + return base64UrlEncode(rando); +} + +export async function generateCodeChallenge(codeVerifier: string) { + const digestOp = await crypto.subtle.digest( + {name: 'SHA-256'}, + new TextEncoder().encode(codeVerifier), + ); + const hash = convertBufferToString(digestOp); + return base64UrlEncode(hash); +} + +export function generateRandomCode() { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return String.fromCharCode.apply(null, Array.from(array)); +} + +function base64UrlEncode(str: string) { + const base64 = btoa(str); + // This is to ensure that the encoding does not have +, /, or = characters in it. + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +function convertBufferToString(hash: ArrayBuffer) { + const uintArray = new Uint8Array(hash); + const numberArray = Array.from(uintArray); + return String.fromCharCode(...numberArray); +} + +export async function generateState(): Promise { + const timestamp = Date.now().toString(); + const randomString = Math.random().toString(36).substring(2); + return timestamp + randomString; +} + +export async function exchangeAccessToken( + session: HydrogenSession, + customerAccountId: string, + customerAccountUrl: string, + origin: string, +) { + const clientId = customerAccountId; + const accessToken = session.get('customer_authorization_code_token'); + + if (!accessToken) + throw new BadRequest( + 'Unauthorized', + 'No access token found in the session. Make sure your session is configured correctly and passed to `createCustomerClient`.', + ); + + const body = new URLSearchParams(); + + body.append('grant_type', 'urn:ietf:params:oauth:grant-type:token-exchange'); + body.append('client_id', clientId); + body.append('audience', CUSTOMER_API_CLIENT_ID); + body.append('subject_token', accessToken); + body.append( + 'subject_token_type', + 'urn:ietf:params:oauth:token-type:access_token', + ); + body.append('scopes', 'https://api.customers.com/auth/customer.graphql'); + + const headers = { + 'content-type': 'application/x-www-form-urlencoded', + 'User-Agent': USER_AGENT, + Origin: origin, + }; + + const response = await fetch(`${customerAccountUrl}/auth/oauth/token`, { + method: 'POST', + headers, + body, + }); + + const data = await response.json(); + + if (data.error) { + throw new BadRequest(data.error_description); + } + + return data.access_token; +} + +export function getNonce(token: string) { + return decodeJwt(token).payload.nonce; +} + +function decodeJwt(token: string) { + const [header, payload, signature] = token.split('.'); + + const decodedHeader = JSON.parse(atob(header)); + const decodedPayload = JSON.parse(atob(payload)); + + return { + header: decodedHeader, + payload: decodedPayload, + signature, + }; +} diff --git a/packages/hydrogen/src/customer/customer.doc.ts b/packages/hydrogen/src/customer/customer.doc.ts new file mode 100644 index 0000000000..2712ce6334 --- /dev/null +++ b/packages/hydrogen/src/customer/customer.doc.ts @@ -0,0 +1,48 @@ +import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs'; + +const data: ReferenceEntityTemplateSchema = { + name: 'createCustomerClient', + category: 'utilities', + isVisualComponent: false, + related: [ + { + name: 'createStorefrontClient', + type: 'utility', + url: '/docs/api/hydrogen/2023-07/utilities/createstorefrontclient', + }, + ], + description: `> Caution: +> This component is in an unstable pre-release state and may have breaking changes in a future release. + +The \`createCustomerClient\` function creates a GraphQL client for querying the [Customer Account API](https://shopify.dev/docs/api/customer). It also provides methods to authenticate and check if the user is logged in. + +See an end to end [example on using the Customer Account API client](https://github.com/Shopify/hydrogen/tree/main/examples/customer-api).`, + type: 'utility', + defaultExample: { + description: 'I am the default example', + codeblock: { + tabs: [ + { + title: 'JavaScript', + code: './customer.example.jsx', + language: 'jsx', + }, + { + title: 'TypeScript', + code: './customer.example.tsx', + language: 'tsx', + }, + ], + title: 'Example code', + }, + }, + definitions: [ + { + title: 'Props', + type: 'CreateCustomerClientGeneratedType', + description: '', + }, + ], +}; + +export default data; diff --git a/packages/hydrogen/src/customer/customer.example.jsx b/packages/hydrogen/src/customer/customer.example.jsx new file mode 100644 index 0000000000..b785a167c4 --- /dev/null +++ b/packages/hydrogen/src/customer/customer.example.jsx @@ -0,0 +1,75 @@ +import {createCustomerClient__unstable} from '@shopify/hydrogen'; +import * as remixBuild from '@remix-run/dev/server-build'; +import { + createRequestHandler, + createCookieSessionStorage, +} from '@shopify/remix-oxygen'; + +export default { + async fetch(request, env, executionContext) { + const session = await HydrogenSession.init(request, [env.SESSION_SECRET]); + + /* Create a Customer API client with your credentials and options */ + const customer = createCustomerClient__unstable({ + /* Runtime utility in serverless environments */ + waitUntil: (p) => executionContext.waitUntil(p), + /* Public Customer Account API token for your store */ + customerAccountId: env.PUBLIC_CUSTOMER_ACCOUNT_ID, + /* Public account URL for your store */ + customerAccountUrl: env.PUBLIC_CUSTOMER_ACCOUNT_URL, + request, + session, + }); + + const handleRequest = createRequestHandler({ + build: remixBuild, + mode: process.env.NODE_ENV, + /* Inject the customer account client in the Remix context */ + getLoadContext: () => ({customer}), + }); + + return handleRequest(request); + }, +}; + +class HydrogenSession { + static async init(request, secrets) { + const storage = createCookieSessionStorage({ + cookie: { + name: 'session', + httpOnly: true, + path: '/', + sameSite: 'lax', + secrets, + }, + }); + + const session = await storage.getSession(request.headers.get('Cookie')); + + return new this(storage, session); + } + + get(key) { + return this.session.get(key); + } + + destroy() { + return this.sessionStorage.destroySession(this.session); + } + + flash(key, value) { + this.session.flash(key, value); + } + + unset(key) { + this.session.unset(key); + } + + set(key, value) { + this.session.set(key, value); + } + + commit() { + return this.sessionStorage.commitSession(this.session); + } +} diff --git a/packages/hydrogen/src/customer/customer.example.tsx b/packages/hydrogen/src/customer/customer.example.tsx new file mode 100644 index 0000000000..be49a65406 --- /dev/null +++ b/packages/hydrogen/src/customer/customer.example.tsx @@ -0,0 +1,86 @@ +import {createCustomerClient__unstable} from '@shopify/hydrogen'; +import * as remixBuild from '@remix-run/dev/server-build'; +import { + createRequestHandler, + createCookieSessionStorage, + type SessionStorage, + type Session, +} from '@shopify/remix-oxygen'; + +export default { + async fetch( + request: Request, + env: Record, + executionContext: ExecutionContext, + ) { + const session = await HydrogenSession.init(request, [env.SESSION_SECRET]); + + /* Create a Customer API client with your credentials and options */ + const customer = createCustomerClient__unstable({ + /* Runtime utility in serverless environments */ + waitUntil: (p) => executionContext.waitUntil(p), + /* Public Customer Account API client ID for your store */ + customerAccountId: env.PUBLIC_CUSTOMER_ACCOUNT_ID, + /* Public account URL for your store */ + customerAccountUrl: env.PUBLIC_CUSTOMER_ACCOUNT_URL, + request, + session, + }); + + const handleRequest = createRequestHandler({ + build: remixBuild, + mode: process.env.NODE_ENV, + /* Inject the customer account client in the Remix context */ + getLoadContext: () => ({customer}), + }); + + return handleRequest(request); + }, +}; + +class HydrogenSession { + constructor( + private sessionStorage: SessionStorage, + private session: Session, + ) {} + + static async init(request: Request, secrets: string[]) { + const storage = createCookieSessionStorage({ + cookie: { + name: 'session', + httpOnly: true, + path: '/', + sameSite: 'lax', + secrets, + }, + }); + + const session = await storage.getSession(request.headers.get('Cookie')); + + return new this(storage, session); + } + + get(key: string) { + return this.session.get(key); + } + + destroy() { + return this.sessionStorage.destroySession(this.session); + } + + flash(key: string, value: any) { + this.session.flash(key, value); + } + + unset(key: string) { + this.session.unset(key); + } + + set(key: string, value: any) { + this.session.set(key, value); + } + + commit() { + return this.sessionStorage.commitSession(this.session); + } +} diff --git a/packages/hydrogen/src/customer/customer.test.ts b/packages/hydrogen/src/customer/customer.test.ts new file mode 100644 index 0000000000..8bb166a09d --- /dev/null +++ b/packages/hydrogen/src/customer/customer.test.ts @@ -0,0 +1,427 @@ +import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; +import crypto from 'node:crypto'; +import { + HydrogenSession, + checkExpires, + clearSession, + refreshToken, +} from './auth.helpers'; +import {createCustomerClient} from './customer'; + +global.crypto = crypto as any; + +vi.mock('./BadRequest', () => { + return { + BadRequest: class BadRequest { + message: string; + constructor(message?: string, helpMessage?: string) { + this.message = `${message} ${helpMessage}`; + } + }, + }; +}); + +vi.stubGlobal( + 'Response', + class Response { + message; + headers; + status; + constructor(body: any, options: any) { + this.headers = options?.headers; + this.status = options?.status; + this.message = body; + } + }, +); + +const fetch = (global.fetch = vi.fn() as any); + +function createFetchResponse(data: T, options: {ok: boolean}) { + return { + json: () => new Promise((resolve) => resolve(data)), + text: async () => JSON.stringify(data), + ok: options.ok, + }; +} + +let session: HydrogenSession; + +describe('customer', () => { + describe('login & logout', () => { + beforeEach(() => { + session = { + commit: vi.fn(() => new Promise((resolve) => resolve('cookie'))), + get: vi.fn(() => 'id_token'), + set: vi.fn(), + unset: vi.fn(), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns true if logged in', async () => { + const customer = createCustomerClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request('https://localhost'), + waitUntil: vi.fn(), + }); + + expect(await customer.isLoggedIn()).toBe(true); + }); + + it('returns false if logged out', async () => { + const customer = createCustomerClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request('https://localhost'), + waitUntil: vi.fn(), + }); + + (session.get as any).mockReturnValueOnce(undefined); + + expect(await customer.isLoggedIn()).toBe(false); + }); + + it('Redirects to the customer account api login url', async () => { + const customer = createCustomerClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request('https://localhost'), + waitUntil: vi.fn(), + }); + + const response = await customer.login(); + + expect(session.set).toHaveBeenCalledWith('state', expect.any(String)); + expect(session.set).toHaveBeenCalledWith('nonce', expect.any(String)); + expect(session.set).toHaveBeenCalledWith( + 'code-verifier', + expect.any(String), + ); + + expect(response.status).toBe(302); + expect(response.headers.get('Set-Cookie')).toBe('cookie'); + const url = new URL(response.headers.get('location')!); + + expect(url.origin).toBe('https://customer-api'); + expect(url.pathname).toBe('/auth/oauth/authorize'); + + const params = new URLSearchParams(url.search); + + expect(params.get('client_id')).toBe('customerAccountId'); + expect(params.get('scope')).toBe( + 'openid email https://api.customers.com/auth/customer.graphql', + ); + expect(params.get('response_type')).toBe('code'); + expect(params.get('redirect_uri')).toBe('https://localhost/authorize'); + expect(params.get('state')).toBeTruthy(); + expect(params.get('nonce')).toBeTruthy(); + expect(params.get('code_challenge')).toBeTruthy(); + expect(params.get('code_challenge_method')).toBe('S256'); + }); + + it('Redirects to the customer account api logout url', async () => { + const customer = createCustomerClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request('https://localhost'), + waitUntil: vi.fn(), + }); + + const response = await customer.logout(); + + expect(response.status).toBe(302); + expect(response.headers.get('Set-Cookie')).toBe('cookie'); + const url = new URL(response.headers.get('location')!); + + expect(url.origin).toBe('https://customer-api'); + expect(url.pathname).toBe('/auth/logout'); + + const params = new URLSearchParams(url.search); + + expect(params.get('id_token_hint')).toBe('id_token'); + + // Session is cleared + expect(session.unset).toHaveBeenCalledWith('code-verifier'); + expect(session.unset).toHaveBeenCalledWith( + 'customer_authorization_code_token', + ); + expect(session.unset).toHaveBeenCalledWith('expires_at'); + expect(session.unset).toHaveBeenCalledWith('id_token'); + expect(session.unset).toHaveBeenCalledWith('refresh_token'); + expect(session.unset).toHaveBeenCalledWith('customer_access_token'); + expect(session.unset).toHaveBeenCalledWith('state'); + expect(session.unset).toHaveBeenCalledWith('nonce'); + }); + }); + + describe('authorize', () => { + beforeEach(() => { + session = { + commit: vi.fn(() => new Promise((resolve) => resolve('cookie'))), + get: vi.fn((v) => v), + set: vi.fn(), + unset: vi.fn(), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('Throws unauthorized if no code or state params are passed', async () => { + const customer = createCustomerClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request('https://localhost'), + waitUntil: vi.fn(), + }); + + async function run() { + await customer.authorize(); + } + + await expect(run).rejects.toThrowError( + 'Unauthorized No code or state parameter found in the redirect URL.', + ); + }); + + it("Throws unauthorized if state doesn't match session value", async () => { + const customer = createCustomerClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request('https://localhost?state=nomatch&code=code'), + waitUntil: vi.fn(), + }); + + async function run() { + await customer.authorize(); + } + + await expect(run).rejects.toThrowError( + 'Unauthorized The session state does not match the state parameter. Make sure that the session is configured correctly and passed to `createCustomerClient`.', + ); + }); + + it('Throws if requesting the token fails', async () => { + const customer = createCustomerClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request('https://localhost?state=state&code=code'), + waitUntil: vi.fn(), + }); + + fetch.mockResolvedValue(createFetchResponse('some text', {ok: false})); + + async function run() { + await customer.authorize(); + } + + await expect(run).rejects.toThrowError('some text'); + }); + + it("Throws if the encoded nonce doesn't match the value in the session", async () => { + const customer = createCustomerClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request('https://localhost?state=state&code=code'), + waitUntil: vi.fn(), + }); + + fetch.mockResolvedValue( + createFetchResponse( + { + access_token: 'access_token', + expires_in: '', + id_token: `${btoa('{}')}.${btoa('{"nonce": "nomatch"}')}.signature`, + refresh_token: 'refresh_token', + }, + {ok: true}, + ), + ); + + async function run() { + await customer.authorize(); + } + + await expect(run).rejects.toThrowError( + 'Unauthorized Returned nonce does not match: nonce !== nomatch', + ); + }); + + it('Redirects on successful authorization and updates session', async () => { + const customer = createCustomerClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request('https://localhost?state=state&code=code'), + waitUntil: vi.fn(), + }); + + fetch.mockResolvedValue( + createFetchResponse( + { + access_token: 'access_token', + expires_in: '', + id_token: `${btoa('{}')}.${btoa('{"nonce": "nonce"}')}.signature`, + refresh_token: 'refresh_token', + }, + {ok: true}, + ), + ); + + const response = await customer.authorize(); + + expect(response.status).toBe(302); + expect(response.headers.get('location')).toBe('/'); + expect(response.headers.get('Set-Cookie')).toStrictEqual( + expect.any(String), + ); + + expect(session.set).toHaveBeenNthCalledWith( + 1, + 'customer_authorization_code_token', + 'access_token', + ); + + expect(session.set).toHaveBeenNthCalledWith( + 2, + 'expires_at', + expect.anything(), + ); + + expect(session.set).toHaveBeenNthCalledWith( + 3, + 'id_token', + 'e30=.eyJub25jZSI6ICJub25jZSJ9.signature', + ); + + expect(session.set).toHaveBeenNthCalledWith( + 4, + 'refresh_token', + 'refresh_token', + ); + + expect(session.set).toHaveBeenNthCalledWith( + 5, + 'customer_access_token', + 'access_token', + ); + }); + }); + + describe('query', () => { + beforeEach(() => { + session = { + commit: vi.fn(() => new Promise((resolve) => resolve('cookie'))), + get: vi.fn((v) => v), + set: vi.fn(), + unset: vi.fn(), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('Throws unauthorized if no access token is in the session', async () => { + const customer = createCustomerClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request('https://localhost'), + waitUntil: vi.fn(), + }); + + (session.get as any).mockReturnValueOnce(undefined); + + async function run() { + await customer.query(`query {...}`); + } + + await expect(run).rejects.toThrowError( + 'Unauthorized Login before querying the Customer Account API.', + ); + }); + + it('Tries to refresh and throws if there is no refresh token', async () => { + const customer = createCustomerClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request('https://localhost'), + waitUntil: vi.fn(), + }); + + (session.get as any).mockImplementation((v: string) => + v === 'expires_at' ? '100' : v === 'refresh_token' ? null : v, + ); + + async function run() { + await customer.query(`query {...}`); + } + + await expect(run).rejects.toThrowError( + 'Unauthorized No refresh_token in the session. Make sure your session is configured correctly and passed to `createCustomerClient`', + ); + }); + + it('Makes query', async () => { + const customer = createCustomerClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request('https://localhost'), + waitUntil: vi.fn(), + }); + + (session.get as any).mockImplementation((v: string) => + v === 'expires_at' ? new Date().getTime() + 10000 + '' : v, + ); + + const someJson = {data: 'json'}; + + fetch.mockResolvedValue(createFetchResponse(someJson, {ok: true})); + + const response = await customer.query(`query {...}`); + expect(response).toBe('json'); + // Session not updated because it's not expired + expect(session.set).not.toHaveBeenCalled(); + }); + + it('Refreshes the token and then makes query', async () => { + const customer = createCustomerClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request('https://localhost'), + waitUntil: vi.fn(), + }); + + (session.get as any).mockImplementation((v: string) => + v === 'expires_at' ? '100' : v, + ); + + const someJson = {data: 'json'}; + + fetch.mockResolvedValue(createFetchResponse(someJson, {ok: true})); + + const response = await customer.query(`query {...}`); + expect(response).toBe('json'); + // Session updated because token was refreshed + expect(session.set).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/hydrogen/src/customer/customer.ts b/packages/hydrogen/src/customer/customer.ts new file mode 100644 index 0000000000..6ddecd771c --- /dev/null +++ b/packages/hydrogen/src/customer/customer.ts @@ -0,0 +1,366 @@ +import { + clearSession, + generateCodeChallenge, + generateCodeVerifier, + generateState, + checkExpires, + USER_AGENT, + exchangeAccessToken, + AccessTokenResponse, + getNonce, + redirect, + Locks, + type HydrogenSession, +} from './auth.helpers'; +import {BadRequest} from './BadRequest'; +import {generateNonce} from '../csp/nonce'; +import { + minifyQuery, + assertQuery, + assertMutation, + throwGraphQLError, + type GraphQLErrorOptions, +} from '../utils/graphql'; +import {parseJSON} from '../utils/parse-json'; +import {hashKey} from '../utils/hash'; +import {CrossRuntimeRequest, getDebugHeaders} from '../utils/request'; + +export type CustomerClient = { + /** Start the OAuth login flow. This function should be called and returned from a Remix action. It redirects the user to a login domain. */ + login: () => Promise; + /** On successful login, the user is redirect back to your app. This function validates the OAuth response and exchanges the authorization code for an access token and refresh token. It also persists the tokens on your session. This function should be called and returned from the Remix loader configured as the redirect URI within the Customer Account API settings. */ + authorize: (redirectPath?: string) => Promise; + /** Returns if the user is logged in. It also checks if the access token is expired and refreshes it if needed. */ + isLoggedIn: () => Promise; + /** Logout the user by clearing the session and redirecting to the login domain. It should be called and returned from a Remix action. */ + logout: () => Promise; + /** Execute a GraphQL query against the Customer Account API. Usually you should first check if the user is logged in before querying the API. */ + query: ( + query: RawGqlString, + options?: {variables: Record}, + ) => Promise; + /** Execute a GraphQL mutation against the Customer Account API. Usually you should first check if the user is logged in before querying the API. */ + mutate: ( + mutation: RawGqlString, + options?: {variables: Record}, + ) => Promise; +}; + +type CustomerClientOptions = { + /** The client requires a session to persist the auth and refresh token. By default Hydrogen ships with cookie session storage, but you can use [another session storage](https://remix.run/docs/en/main/utils/sessions) implementation. */ + session: HydrogenSession; + /** Unique UUID prefixed with `shp_` associated with the application, this should be visible in the customer account api settings in the Hydrogen admin channel. */ + customerAccountId: string; + /** The account URL associated with the application, this should be visible in the customer account api settings in the Hydrogen admin channel. */ + customerAccountUrl: string; + /** Override the version of the API */ + customerApiVersion?: string; + /** The object for the current Request. It should be provided by your platform. */ + request: CrossRuntimeRequest; + /** The waitUntil function is used to keep the current request/response lifecycle alive even after a response has been sent. It should be provided by your platform. */ + waitUntil?: ExecutionContext['waitUntil']; +}; + +export function createCustomerClient({ + session, + customerAccountId, + customerAccountUrl, + customerApiVersion = '2023-10', + request, + waitUntil, +}: CustomerClientOptions): CustomerClient { + if (!request?.url) { + throw new Error( + '[h2:error:createCustomerClient] The request object does not contain a URL.', + ); + } + const url = new URL(request.url); + const origin = + url.protocol === 'http:' ? url.origin.replace('http', 'https') : url.origin; + + const locks: Locks = {}; + + const logSubRequestEvent = + process.env.NODE_ENV === 'development' + ? (query: string, startTime: number) => { + globalThis.__H2O_LOG_EVENT?.({ + eventType: 'subrequest', + url: `https://shopify.dev/?${hashKey([ + `Customer Account `, + /((query|mutation) [^\s\(]+)/g.exec(query)?.[0] || + query.substring(0, 10), + ])}`, + startTime, + waitUntil, + ...getDebugHeaders(request), + }); + } + : undefined; + + async function fetchCustomerAPI({ + query, + type, + variables = {}, + }: { + query: string; + type: 'query' | 'mutation'; + variables?: Record; + }) { + const accessToken = session.get('customer_access_token'); + const expiresAt = session.get('expires_at'); + + if (!accessToken || !expiresAt) + throw new BadRequest( + 'Unauthorized', + 'Login before querying the Customer Account API.', + ); + + await checkExpires({ + locks, + expiresAt, + session, + customerAccountId, + customerAccountUrl, + origin, + }); + + const startTime = new Date().getTime(); + + const response = await fetch( + `${customerAccountUrl}/account/customer/api/${customerApiVersion}/graphql`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': USER_AGENT, + Origin: origin, + Authorization: accessToken, + }, + body: JSON.stringify({ + operationName: 'SomeQuery', + query, + variables, + }), + }, + ); + + logSubRequestEvent?.(query, startTime); + + const body = await response.text(); + + const errorOptions: GraphQLErrorOptions = { + response, + type, + query, + queryVariables: variables, + errors: undefined, + client: 'customer', + }; + + if (!response.ok) { + /** + * The Customer API might return a string error, or a JSON-formatted {error: string}. + * We try both and conform them to a single {errors} format. + */ + let errors; + try { + errors = parseJSON(body); + } catch (_e) { + errors = [{message: body}]; + } + + throwGraphQLError({...errorOptions, errors}); + } + + try { + return parseJSON(body).data; + } catch (e) { + throwGraphQLError({...errorOptions, errors: [{message: body}]}); + } + } + + return { + login: async () => { + const loginUrl = new URL(customerAccountUrl + '/auth/oauth/authorize'); + + const state = await generateState(); + const nonce = await generateNonce(); + + loginUrl.searchParams.set('client_id', customerAccountId); + loginUrl.searchParams.set('scope', 'openid email'); + loginUrl.searchParams.append('response_type', 'code'); + loginUrl.searchParams.append('redirect_uri', origin + '/authorize'); + loginUrl.searchParams.set( + 'scope', + 'openid email https://api.customers.com/auth/customer.graphql', + ); + loginUrl.searchParams.append('state', state); + loginUrl.searchParams.append('nonce', nonce); + + const verifier = await generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + + session.set('code-verifier', verifier); + session.set('state', state); + session.set('nonce', nonce); + + loginUrl.searchParams.append('code_challenge', challenge); + loginUrl.searchParams.append('code_challenge_method', 'S256'); + + return redirect(loginUrl.toString(), { + headers: { + 'Set-Cookie': await session.commit(), + }, + }); + }, + logout: async () => { + const idToken = session.get('id_token'); + + clearSession(session); + + return redirect( + `${customerAccountUrl}/auth/logout?id_token_hint=${idToken}`, + { + status: 302, + + headers: { + 'Set-Cookie': await session.commit(), + }, + }, + ); + }, + isLoggedIn: async () => { + const expiresAt = session.get('expires_at'); + + if (!session.get('customer_access_token') || !expiresAt) return false; + + const startTime = new Date().getTime(); + + try { + await checkExpires({ + locks, + expiresAt, + session, + customerAccountId, + customerAccountUrl, + origin, + }); + + logSubRequestEvent?.(' check expires', startTime); + } catch { + return false; + } + + return true; + }, + mutate(mutation, options) { + mutation = minifyQuery(mutation); + assertMutation(mutation, 'customer.mutate'); + + return fetchCustomerAPI({query: mutation, type: 'mutation', ...options}); + }, + query(query, options) { + query = minifyQuery(query); + assertQuery(query, 'customer.query'); + + return fetchCustomerAPI({query, type: 'query', ...options}); + }, + authorize: async (redirectPath = '/') => { + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + + if (!code || !state) { + clearSession(session); + throw new BadRequest( + 'Unauthorized', + 'No code or state parameter found in the redirect URL.', + ); + } + + if (session.get('state') !== state) { + clearSession(session); + throw new BadRequest( + 'Unauthorized', + 'The session state does not match the state parameter. Make sure that the session is configured correctly and passed to `createCustomerClient`.', + ); + } + + const clientId = customerAccountId; + const body = new URLSearchParams(); + + body.append('grant_type', 'authorization_code'); + body.append('client_id', clientId); + body.append('redirect_uri', origin + '/authorize'); + body.append('code', code); + + // Public Client + const codeVerifier = session.get('code-verifier'); + + if (!codeVerifier) + throw new BadRequest( + 'Unauthorized', + 'No code verifier found in the session. Make sure that the session is configured correctly and passed to `createCustomerClient`.', + ); + + body.append('code_verifier', codeVerifier); + + const headers = { + 'content-type': 'application/x-www-form-urlencoded', + 'User-Agent': USER_AGENT, + Origin: origin, + }; + + const response = await fetch(`${customerAccountUrl}/auth/oauth/token`, { + method: 'POST', + headers, + body, + }); + + if (!response.ok) { + throw new Response(await response.text(), { + status: response.status, + headers: { + 'Content-Type': 'text/html; charset=utf-8', + }, + }); + } + + const {access_token, expires_in, id_token, refresh_token} = + await response.json(); + + const sessionNonce = session.get('nonce'); + const responseNonce = await getNonce(id_token); + + if (sessionNonce !== responseNonce) { + throw new BadRequest( + 'Unauthorized', + `Returned nonce does not match: ${sessionNonce} !== ${responseNonce}`, + ); + } + + session.set('customer_authorization_code_token', access_token); + session.set( + 'expires_at', + new Date(new Date().getTime() + (expires_in! - 120) * 1000).getTime() + + '', + ); + session.set('id_token', id_token); + session.set('refresh_token', refresh_token); + + const customerAccessToken = await exchangeAccessToken( + session, + customerAccountId, + customerAccountUrl, + origin, + ); + + session.set('customer_access_token', customerAccessToken); + + return redirect(redirectPath, { + headers: { + 'Set-Cookie': await session.commit(), + }, + }); + }, + }; +} diff --git a/packages/hydrogen/src/index.ts b/packages/hydrogen/src/index.ts index 4c7da454b0..cd59e8ec4a 100644 --- a/packages/hydrogen/src/index.ts +++ b/packages/hydrogen/src/index.ts @@ -15,6 +15,8 @@ export {Seo} from './seo/seo'; export {type SeoConfig} from './seo/generate-seo-tags'; export type {SeoHandleFunction} from './seo/seo'; export {Pagination, getPaginationVariables} from './pagination/Pagination'; +export {createCustomerClient as createCustomerClient__unstable} from './customer/customer'; +export type {CustomerClient} from './customer/customer'; export {CartForm, type CartActionInput} from './cart/CartForm'; export {cartCreateDefault} from './cart/queries/cartCreateDefault'; diff --git a/packages/hydrogen/src/pagination/getPaginationVariables.doc.ts b/packages/hydrogen/src/pagination/getPaginationVariables.doc.ts index 154d90f364..35aea63b75 100644 --- a/packages/hydrogen/src/pagination/getPaginationVariables.doc.ts +++ b/packages/hydrogen/src/pagination/getPaginationVariables.doc.ts @@ -11,10 +11,7 @@ const data: ReferenceEntityTemplateSchema = { url: '/docs/api/hydrogen/2023-10/components/pagination', }, ], - description: `> Caution: -> This component is in an unstable pre-release state and may have breaking changes in a future release. - -The \`getPaginationVariables\` function is used with the [\`\`](/docs/api/hydrogen/components/pagnination) component to generate the variables needed to fetch paginated data from the Storefront API. The returned variables should be used within your storefront GraphQL query.`, + description: `The \`getPaginationVariables\` function is used with the [\`\`](/docs/api/hydrogen/components/pagnination) component to generate the variables needed to fetch paginated data from the Storefront API. The returned variables should be used within your storefront GraphQL query.`, type: 'utility', defaultExample: { description: 'I am the default example', diff --git a/packages/hydrogen/src/storefront.ts b/packages/hydrogen/src/storefront.ts index cf227c5bb7..04290ce823 100644 --- a/packages/hydrogen/src/storefront.ts +++ b/packages/hydrogen/src/storefront.ts @@ -1,13 +1,12 @@ import { createStorefrontClient as createStorefrontUtilities, getShopifyCookies, - type StorefrontApiResponseOk, - type StorefrontClientProps, SHOPIFY_S, SHOPIFY_Y, SHOPIFY_STOREFRONT_ID_HEADER, SHOPIFY_STOREFRONT_Y_HEADER, SHOPIFY_STOREFRONT_S_HEADER, + type StorefrontClientProps, } from '@shopify/hydrogen-react'; import type {ExecutionArgs} from 'graphql'; import {fetchWithServerCache, checkGraphQLErrors} from './cache/fetch'; @@ -35,8 +34,14 @@ import { } from '@shopify/hydrogen-react/storefront-api-types'; import {warnOnce} from './utils/warning'; import {LIB_VERSION} from './version'; - -type StorefrontApiResponse = StorefrontApiResponseOk; +import { + minifyQuery, + assertQuery, + assertMutation, + throwGraphQLError, + type GraphQLApiResponse, + type GraphQLErrorOptions, +} from './utils/graphql'; export type I18nBase = { language: LanguageCode; @@ -212,16 +217,6 @@ export const StorefrontApiError = class extends Error {} as ErrorConstructor; export const isStorefrontApiError = (error: any) => error instanceof StorefrontApiError; -const isQueryRE = /(^|}\s)query[\s({]/im; -const isMutationRE = /(^|}\s)mutation[\s({]/im; - -function minifyQuery(string: string) { - return string - .replace(/\s*#.*$/gm, '') // Remove GQL comments - .replace(/\s+/gm, ' ') // Minify spaces - .trim(); -} - const defaultI18n: I18nBase = {language: 'EN', country: 'US'}; /** @@ -349,7 +344,7 @@ export function createStorefrontClient( }, }); - const errorOptions: StorefrontErrorOptions = { + const errorOptions: GraphQLErrorOptions = { response, type: mutation ? 'mutation' : 'query', query, @@ -369,13 +364,13 @@ export function createStorefrontClient( errors = [{message: body}]; } - throwError({...errorOptions, errors}); + throwGraphQLError({...errorOptions, errors}); } - const {data, errors} = body as StorefrontApiResponse; + const {data, errors} = body as GraphQLApiResponse; if (errors?.length) { - throwError({ + throwGraphQLError({ ...errorOptions, errors, ErrorConstructor: StorefrontApiError, @@ -403,11 +398,7 @@ export function createStorefrontClient( */ query: ((query: string, payload) => { query = minifyQuery(query); - if (isMutationRE.test(query)) { - throw new Error( - '[h2:error:storefront.query] Cannot execute mutations', - ); - } + assertQuery(query, 'storefront.query'); const result = fetchStorefrontApi({ ...payload, @@ -435,11 +426,7 @@ export function createStorefrontClient( */ mutate: ((mutation: string, payload) => { mutation = minifyQuery(mutation); - if (isQueryRE.test(mutation)) { - throw new Error( - '[h2:error:storefront.mutate] Cannot execute queries', - ); - } + assertMutation(mutation, 'storefront.mutate'); const result = fetchStorefrontApi({ ...payload, @@ -486,46 +473,3 @@ export function createStorefrontClient( }, }; } - -type StorefrontErrorOptions = { - response: Response; - errors: StorefrontApiResponse['errors']; - type: 'query' | 'mutation'; - query: string; - queryVariables: Record; - ErrorConstructor?: ErrorConstructor; -}; - -function throwError({ - response, - errors, - type, - query, - queryVariables, - ErrorConstructor = Error, -}: StorefrontErrorOptions) { - const requestId = response.headers.get('x-request-id'); - const errorMessage = - (typeof errors === 'string' - ? errors - : errors?.map?.((error) => error.message).join('\n')) || - `API response error: ${response.status}`; - - throw new ErrorConstructor( - `[h2:error:storefront.${type}] ` + - errorMessage + - (requestId ? ` - Request ID: ${requestId}` : ''), - { - cause: JSON.stringify({ - errors, - requestId, - ...(process.env.NODE_ENV === 'development' && { - graphql: { - query, - variables: JSON.stringify(queryVariables), - }, - }), - }), - }, - ); -} diff --git a/packages/hydrogen/src/utils/graphql.ts b/packages/hydrogen/src/utils/graphql.ts new file mode 100644 index 0000000000..fa64976004 --- /dev/null +++ b/packages/hydrogen/src/utils/graphql.ts @@ -0,0 +1,70 @@ +import type {StorefrontApiResponseOk} from '@shopify/hydrogen-react'; + +export function minifyQuery(string: T) { + return string + .replace(/\s*#.*$/gm, '') // Remove GQL comments + .replace(/\s+/gm, ' ') // Minify spaces + .trim() as T; +} + +const IS_QUERY_RE = /(^|}\s)query[\s({]/im; +const IS_MUTATION_RE = /(^|}\s)mutation[\s({]/im; + +export function assertQuery(query: string, callerName: string) { + if (!IS_QUERY_RE.test(query)) { + throw new Error(`[h2:error:${callerName}] Can only execute queries`); + } +} + +export function assertMutation(query: string, callerName: string) { + if (!IS_MUTATION_RE.test(query)) { + throw new Error(`[h2:error:${callerName}] Can only execute mutations`); + } +} + +export type GraphQLApiResponse = StorefrontApiResponseOk; + +export type GraphQLErrorOptions = { + response: Response; + errors: GraphQLApiResponse['errors']; + type: 'query' | 'mutation'; + query: string; + queryVariables: Record; + ErrorConstructor?: ErrorConstructor; + client?: string; +}; + +export function throwGraphQLError({ + response, + errors, + type, + query, + queryVariables, + ErrorConstructor = Error, + client = 'storefront', +}: GraphQLErrorOptions): never { + const requestId = response.headers.get('x-request-id'); + const errorMessage = + (typeof errors === 'string' + ? errors + : errors?.map?.((error) => error.message).join('\n')) || + `API response error: ${response.status}`; + + throw new ErrorConstructor( + `[h2:error:${client}.${type}] ` + + errorMessage + + (requestId ? ` - Request ID: ${requestId}` : ''), + { + cause: JSON.stringify({ + errors, + requestId, + ...(process.env.NODE_ENV === 'development' && { + graphql: { + query, + variables: JSON.stringify(queryVariables), + }, + }), + }), + }, + ); +}