diff --git a/.changeset/healthy-rats-confess.md b/.changeset/healthy-rats-confess.md new file mode 100644 index 0000000000..b4566fa7c6 --- /dev/null +++ b/.changeset/healthy-rats-confess.md @@ -0,0 +1,112 @@ +--- +'skeleton': patch +--- + +1. Create a app/lib/context file and use `createHydrogenContext` in it. + +```.ts +// in app/lib/context + +import {createHydrogenContext} from '@shopify/hydrogen'; + +export async function createAppLoadContext( + request: Request, + env: Env, + executionContext: ExecutionContext, +) { + const hydrogenContext = createHydrogenContext({ + env, + request, + cache, + waitUntil, + session, + i18n: {language: 'EN', country: 'US'}, + cart: { + queryFragment: CART_QUERY_FRAGMENT, + }, + // ensure to overwrite any options that is not using the default values from your server.ts + }); + + return { + ...hydrogenContext, + // declare additional Remix loader context + }; +} + +``` + +2. Use `createAppLoadContext` method in server.ts Ensure to overwrite any options that is not using the default values in `createHydrogenContext`. + +```diff +// in server.ts + +- import { +- createCartHandler, +- createStorefrontClient, +- createCustomerAccountClient, +- } from '@shopify/hydrogen'; ++ import {createAppLoadContext} from '~/lib/context'; + +export default { + async fetch( + request: Request, + env: Env, + executionContext: ExecutionContext, + ): Promise { + +- const {storefront} = createStorefrontClient( +- ... +- ); + +- const customerAccount = createCustomerAccountClient( +- ... +- ); + +- const cart = createCartHandler( +- ... +- ); + ++ const appLoadContext = await createAppLoadContext( ++ request, ++ env, ++ executionContext, ++ ); + + /** + * Create a Remix request handler and pass + * Hydrogen's Storefront client to the loader context. + */ + const handleRequest = createRequestHandler({ + build: remixBuild, + mode: process.env.NODE_ENV, +- getLoadContext: (): AppLoadContext => ({ +- session, +- storefront, +- customerAccount, +- cart, +- env, +- waitUntil, +- }), ++ getLoadContext: () => appLoadContext, + }); + } +``` + +3. Use infer type for AppLoadContext in env.d.ts + +```diff +// in env.d.ts + ++ import type {createAppLoadContext} from '~/lib/context'; + ++ interface AppLoadContext extends Awaited> { +- interface AppLoadContext { +- env: Env; +- cart: HydrogenCart; +- storefront: Storefront; +- customerAccount: CustomerAccount; +- session: AppSession; +- waitUntil: ExecutionContext['waitUntil']; +} + +``` diff --git a/.changeset/light-boxes-smile.md b/.changeset/light-boxes-smile.md new file mode 100644 index 0000000000..a76c9b09a8 --- /dev/null +++ b/.changeset/light-boxes-smile.md @@ -0,0 +1,5 @@ +--- +'@shopify/hydrogen': patch +--- + +Create `createHydrogenContext` that combined `createStorefrontClient`, `createCustomerAccountClient` and `createCartHandler`. diff --git a/.changeset/quiet-falcons-draw.md b/.changeset/quiet-falcons-draw.md new file mode 100644 index 0000000000..7213995ea3 --- /dev/null +++ b/.changeset/quiet-falcons-draw.md @@ -0,0 +1,5 @@ +--- +'@shopify/create-hydrogen': patch +--- + +starter template updated diff --git a/.changeset/rich-falcons-remain.md b/.changeset/rich-falcons-remain.md new file mode 100644 index 0000000000..c11c4e56e8 --- /dev/null +++ b/.changeset/rich-falcons-remain.md @@ -0,0 +1,24 @@ +--- +'skeleton': patch +--- + +Use type `HydrogenEnv` for all the env.d.ts + +```diff +// in env.d.ts + ++ import type {HydrogenEnv} from '@shopify/hydrogen'; + ++ interface Env extends HydrogenEnv {} +- interface Env { +- SESSION_SECRET: string; +- PUBLIC_STOREFRONT_API_TOKEN: string; +- PRIVATE_STOREFRONT_API_TOKEN: string; +- PUBLIC_STORE_DOMAIN: string; +- PUBLIC_STOREFRONT_ID: string; +- PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string; +- PUBLIC_CUSTOMER_ACCOUNT_API_URL: string; +- PUBLIC_CHECKOUT_DOMAIN: string; +- } + +``` diff --git a/docs/shopify-dev/analytics-setup/ts/env.d.ts b/docs/shopify-dev/analytics-setup/ts/env.d.ts index aedd43b9a8..b71cc30111 100644 --- a/docs/shopify-dev/analytics-setup/ts/env.d.ts +++ b/docs/shopify-dev/analytics-setup/ts/env.d.ts @@ -6,10 +6,9 @@ import '@total-typescript/ts-reset'; import type { - Storefront, - CustomerAccount, - HydrogenCart, + HydrogenContext, HydrogenSessionData, + HydrogenEnv, } from '@shopify/hydrogen'; import type {AppSession} from '~/lib/session'; @@ -19,38 +18,17 @@ declare global { */ const process: {env: {NODE_ENV: 'production' | 'development'}}; - /** - * Declare expected Env parameter in fetch handler. - */ - interface Env { - SESSION_SECRET: string; - PUBLIC_STOREFRONT_API_TOKEN: string; - PRIVATE_STOREFRONT_API_TOKEN: string; - PUBLIC_STORE_DOMAIN: string; - PUBLIC_STOREFRONT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_URL: string; - // [START envtype] - PUBLIC_CHECKOUT_DOMAIN: string; - // [END envtype] + interface Env extends HydrogenEnv { + // declare additional Env parameter use in the fetch handler and Remix loader context here } } declare module '@shopify/remix-oxygen' { - /** - * Declare local additions to the Remix loader context. - */ - interface AppLoadContext { - env: Env; - cart: HydrogenCart; - storefront: Storefront; - customerAccount: CustomerAccount; - session: AppSession; - waitUntil: ExecutionContext['waitUntil']; + interface AppLoadContext extends HydrogenContext { + // declare additional Remix loader context here } - /** - * Declare local additions to the Remix session data. - */ - interface SessionData extends HydrogenSessionData {} + interface SessionData extends HydrogenSessionData { + // declare local additions to the Remix session data here + } } diff --git a/examples/b2b/app/lib/context.ts b/examples/b2b/app/lib/context.ts new file mode 100644 index 0000000000..4e23ed07df --- /dev/null +++ b/examples/b2b/app/lib/context.ts @@ -0,0 +1,50 @@ +import {createHydrogenContext} from '@shopify/hydrogen'; +import {AppSession} from '~/lib/session'; +import {CART_QUERY_FRAGMENT} from '~/lib/fragments'; + +/** + * The context implementation is separate from server.ts + * so that type can be extracted for AppLoadContext + * */ +export async function createAppLoadContext( + request: Request, + env: Env, + executionContext: ExecutionContext, +) { + /** + * Open a cache instance in the worker and a custom session instance. + */ + if (!env?.SESSION_SECRET) { + throw new Error('SESSION_SECRET environment variable is not set'); + } + + const waitUntil = executionContext.waitUntil.bind(executionContext); + const [cache, session] = await Promise.all([ + caches.open('hydrogen'), + AppSession.init(request, [env.SESSION_SECRET]), + ]); + + const hydrogenContext = createHydrogenContext({ + env, + request, + cache, + waitUntil, + session, + i18n: {language: 'EN', country: 'US'}, + /***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/ + customerAccount: { + unstableB2b: true, + }, + /********** EXAMPLE UPDATE END ************/ + /***********************************************/ + cart: { + queryFragment: CART_QUERY_FRAGMENT, + }, + }); + + return { + ...hydrogenContext, + // declare additional Remix loader context + }; +} diff --git a/examples/b2b/app/remix.env.d.ts b/examples/b2b/app/remix.env.d.ts deleted file mode 100644 index 274956e557..0000000000 --- a/examples/b2b/app/remix.env.d.ts +++ /dev/null @@ -1,54 +0,0 @@ -/// -/// -/// - -// Enhance TypeScript's built-in typings. -import '@total-typescript/ts-reset'; - -import type { - Storefront, - CustomerAccount, - HydrogenCart, - HydrogenSessionData, -} from '@shopify/hydrogen'; -import type {AppSession} from '~/lib/session'; - -declare global { - /** - * A global `process` object is only available during build to access NODE_ENV. - */ - const process: {env: {NODE_ENV: 'production' | 'development'}}; - - /** - * Declare expected Env parameter in fetch handler. - */ - interface Env { - SESSION_SECRET: string; - PUBLIC_STOREFRONT_API_TOKEN: string; - PRIVATE_STOREFRONT_API_TOKEN: string; - PUBLIC_STORE_DOMAIN: string; - PUBLIC_STOREFRONT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_URL: string; - PUBLIC_CHECKOUT_DOMAIN: string; - } -} - -declare module '@shopify/remix-oxygen' { - /** - * Declare local additions to the Remix loader context. - */ - interface AppLoadContext { - env: Env; - cart: HydrogenCart; - storefront: Storefront; - customerAccount: CustomerAccount; - session: AppSession; - waitUntil: ExecutionContext['waitUntil']; - } - - /** - * Declare local additions to the Remix session data. - */ - interface SessionData extends HydrogenSessionData {} -} diff --git a/examples/b2b/server.ts b/examples/b2b/server.ts deleted file mode 100644 index 7279d99887..0000000000 --- a/examples/b2b/server.ts +++ /dev/null @@ -1,124 +0,0 @@ -// @ts-ignore -// Virtual entry point for the app -import * as remixBuild from 'virtual:remix/server-build'; -import { - cartGetIdDefault, - cartSetIdDefault, - createCartHandler, - createStorefrontClient, - storefrontRedirect, - createCustomerAccountClient, -} from '@shopify/hydrogen'; -import { - createRequestHandler, - getStorefrontHeaders, - type AppLoadContext, -} from '@shopify/remix-oxygen'; -import {AppSession} from '~/lib/session'; -import {CART_QUERY_FRAGMENT} from '~/lib/fragments'; - -/** - * Export a fetch handler in module format. - */ -export default { - async fetch( - request: Request, - env: Env, - executionContext: ExecutionContext, - ): Promise { - try { - /** - * Open a cache instance in the worker and a custom session instance. - */ - if (!env?.SESSION_SECRET) { - throw new Error('SESSION_SECRET environment variable is not set'); - } - - const waitUntil = executionContext.waitUntil.bind(executionContext); - const [cache, session] = await Promise.all([ - caches.open('hydrogen'), - AppSession.init(request, [env.SESSION_SECRET]), - ]); - - /** - * Create Hydrogen's Storefront client. - */ - const {storefront} = createStorefrontClient({ - cache, - waitUntil, - i18n: {language: 'EN', country: 'US'}, - publicStorefrontToken: env.PUBLIC_STOREFRONT_API_TOKEN, - privateStorefrontToken: env.PRIVATE_STOREFRONT_API_TOKEN, - storeDomain: env.PUBLIC_STORE_DOMAIN, - storefrontId: env.PUBLIC_STOREFRONT_ID, - storefrontHeaders: getStorefrontHeaders(request), - }); - - /** - * Create a client for Customer Account API. - */ - const customerAccount = createCustomerAccountClient({ - waitUntil, - request, - session, - customerAccountId: env.PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID, - customerAccountUrl: env.PUBLIC_CUSTOMER_ACCOUNT_API_URL, - /***********************************************/ - /********** EXAMPLE UPDATE STARTS ************/ - unstableB2b: true, - /********** EXAMPLE UPDATE END ************/ - /***********************************************/ - }); - - /* - * Create a cart handler that will be used to - * create and update the cart in the session. - */ - const cart = createCartHandler({ - storefront, - customerAccount, - getCartId: cartGetIdDefault(request.headers), - setCartId: cartSetIdDefault(), - cartQueryFragment: CART_QUERY_FRAGMENT, - }); - - /** - * Create a Remix request handler and pass - * Hydrogen's Storefront client to the loader context. - */ - const handleRequest = createRequestHandler({ - build: remixBuild, - mode: process.env.NODE_ENV, - getLoadContext: (): AppLoadContext => ({ - session, - storefront, - customerAccount, - cart, - env, - waitUntil, - }), - }); - - const response = await handleRequest(request); - - if (session.isPending) { - response.headers.set('Set-Cookie', await session.commit()); - } - - if (response.status === 404) { - /** - * Check for redirects only when there's a 404 from the app. - * If the redirect doesn't exist, then `storefrontRedirect` - * will pass through the 404 response. - */ - return storefrontRedirect({request, response, storefront}); - } - - return response; - } catch (error) { - // eslint-disable-next-line no-console - console.error(error); - return new Response('An unexpected error occurred', {status: 500}); - } - }, -}; diff --git a/examples/classic-remix/remix.env.d.ts b/examples/classic-remix/remix.env.d.ts index 274956e557..962c980662 100644 --- a/examples/classic-remix/remix.env.d.ts +++ b/examples/classic-remix/remix.env.d.ts @@ -1,4 +1,8 @@ +/***********************************************/ +/********** EXAMPLE UPDATE STARTS ************/ /// +/********** EXAMPLE UPDATE END ************/ +/***********************************************/ /// /// @@ -6,12 +10,11 @@ import '@total-typescript/ts-reset'; import type { - Storefront, - CustomerAccount, - HydrogenCart, + HydrogenContext, HydrogenSessionData, + HydrogenEnv, } from '@shopify/hydrogen'; -import type {AppSession} from '~/lib/session'; +import type {createAppLoadContext} from '~/lib/context'; declare global { /** @@ -19,36 +22,18 @@ declare global { */ const process: {env: {NODE_ENV: 'production' | 'development'}}; - /** - * Declare expected Env parameter in fetch handler. - */ - interface Env { - SESSION_SECRET: string; - PUBLIC_STOREFRONT_API_TOKEN: string; - PRIVATE_STOREFRONT_API_TOKEN: string; - PUBLIC_STORE_DOMAIN: string; - PUBLIC_STOREFRONT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_URL: string; - PUBLIC_CHECKOUT_DOMAIN: string; + interface Env extends HydrogenEnv { + // declare additional Env parameter use in the fetch handler and Remix loader context here } } declare module '@shopify/remix-oxygen' { - /** - * Declare local additions to the Remix loader context. - */ - interface AppLoadContext { - env: Env; - cart: HydrogenCart; - storefront: Storefront; - customerAccount: CustomerAccount; - session: AppSession; - waitUntil: ExecutionContext['waitUntil']; + interface AppLoadContext + extends Awaited> { + // to change context type, change the return of createAppLoadContext() instead } - /** - * Declare local additions to the Remix session data. - */ - interface SessionData extends HydrogenSessionData {} + interface SessionData extends HydrogenSessionData { + // declare local additions to the Remix session data here + } } diff --git a/examples/classic-remix/server.ts b/examples/classic-remix/server.ts index 34e7e709d4..f2baff0566 100644 --- a/examples/classic-remix/server.ts +++ b/examples/classic-remix/server.ts @@ -1,20 +1,12 @@ // Virtual entry point for the app +/***********************************************/ +/********** EXAMPLE UPDATE STARTS ************/ import * as remixBuild from '@remix-run/dev/server-build'; -import { - cartGetIdDefault, - cartSetIdDefault, - createCartHandler, - createStorefrontClient, - storefrontRedirect, - createCustomerAccountClient, -} from '@shopify/hydrogen'; -import { - createRequestHandler, - getStorefrontHeaders, - type AppLoadContext, -} from '@shopify/remix-oxygen'; -import {AppSession} from '~/lib/session'; -import {CART_QUERY_FRAGMENT} from '~/lib/fragments'; +/********** EXAMPLE UPDATE END ************/ +/***********************************************/ +import {storefrontRedirect} from '@shopify/hydrogen'; +import {createRequestHandler} from '@shopify/remix-oxygen'; +import {createAppLoadContext} from '~/lib/context'; /** * Export a fetch handler in module format. @@ -26,55 +18,11 @@ export default { executionContext: ExecutionContext, ): Promise { try { - /** - * Open a cache instance in the worker and a custom session instance. - */ - if (!env?.SESSION_SECRET) { - throw new Error('SESSION_SECRET environment variable is not set'); - } - - const waitUntil = executionContext.waitUntil.bind(executionContext); - const [cache, session] = await Promise.all([ - caches.open('hydrogen'), - AppSession.init(request, [env.SESSION_SECRET]), - ]); - - /** - * Create Hydrogen's Storefront client. - */ - const {storefront} = createStorefrontClient({ - cache, - waitUntil, - i18n: {language: 'EN', country: 'US'}, - publicStorefrontToken: env.PUBLIC_STOREFRONT_API_TOKEN, - privateStorefrontToken: env.PRIVATE_STOREFRONT_API_TOKEN, - storeDomain: env.PUBLIC_STORE_DOMAIN, - storefrontId: env.PUBLIC_STOREFRONT_ID, - storefrontHeaders: getStorefrontHeaders(request), - }); - - /** - * Create a client for Customer Account API. - */ - const customerAccount = createCustomerAccountClient({ - waitUntil, + const appLoadContext = await createAppLoadContext( request, - session, - customerAccountId: env.PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID, - customerAccountUrl: env.PUBLIC_CUSTOMER_ACCOUNT_API_URL, - }); - - /* - * Create a cart handler that will be used to - * create and update the cart in the session. - */ - const cart = createCartHandler({ - storefront, - customerAccount, - getCartId: cartGetIdDefault(request.headers), - setCartId: cartSetIdDefault(), - cartQueryFragment: CART_QUERY_FRAGMENT, - }); + env, + executionContext, + ); /** * Create a Remix request handler and pass @@ -83,20 +31,16 @@ export default { const handleRequest = createRequestHandler({ build: remixBuild, mode: process.env.NODE_ENV, - getLoadContext: (): AppLoadContext => ({ - session, - storefront, - customerAccount, - cart, - env, - waitUntil, - }), + getLoadContext: () => appLoadContext, }); const response = await handleRequest(request); - if (session.isPending) { - response.headers.set('Set-Cookie', await session.commit()); + if (appLoadContext.session.isPending) { + response.headers.set( + 'Set-Cookie', + await appLoadContext.session.commit(), + ); } if (response.status === 404) { @@ -105,7 +49,11 @@ export default { * If the redirect doesn't exist, then `storefrontRedirect` * will pass through the 404 response. */ - return storefrontRedirect({request, response, storefront}); + return storefrontRedirect({ + request, + response, + storefront: appLoadContext.storefront, + }); } return response; diff --git a/examples/custom-cart-method/app/lib/context.ts b/examples/custom-cart-method/app/lib/context.ts new file mode 100644 index 0000000000..346f57f95d --- /dev/null +++ b/examples/custom-cart-method/app/lib/context.ts @@ -0,0 +1,81 @@ +import { + createHydrogenContext, + cartLinesUpdateDefault, + cartGetIdDefault, +} from '@shopify/hydrogen'; +import {AppSession} from '~/lib/session'; +import {CART_QUERY_FRAGMENT, PRODUCT_VARIANT_QUERY} from '~/lib/fragments'; +import type { + SelectedOptionInput, + CartLineUpdateInput, +} from '@shopify/hydrogen/storefront-api-types'; + +/** + * The context implementation is separate from server.ts + * so that type can be extracted for AppLoadContext + * */ +export async function createAppLoadContext( + request: Request, + env: Env, + executionContext: ExecutionContext, +) { + /** + * Open a cache instance in the worker and a custom session instance. + */ + if (!env?.SESSION_SECRET) { + throw new Error('SESSION_SECRET environment variable is not set'); + } + + const waitUntil = executionContext.waitUntil.bind(executionContext); + const [cache, session] = await Promise.all([ + caches.open('hydrogen'), + AppSession.init(request, [env.SESSION_SECRET]), + ]); + + const hydrogenContext = createHydrogenContext({ + env, + request, + cache, + waitUntil, + session, + i18n: {language: 'EN', country: 'US'}, + cart: { + queryFragment: CART_QUERY_FRAGMENT, + /***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/ + customMethods: { + updateLineByOptions: async ( + productId: string, + selectedOptions: SelectedOptionInput[], + line: CartLineUpdateInput, + ) => { + const {product} = await hydrogenContext.storefront.query( + PRODUCT_VARIANT_QUERY, + { + variables: { + productId, + selectedOptions, + }, + }, + ); + + const lines = [ + {...line, merchandiseId: product?.selectedVariant?.id}, + ]; + + return await cartLinesUpdateDefault({ + storefront: hydrogenContext.storefront, + getCartId: cartGetIdDefault(request.headers), + })(lines); + }, + }, + /********** EXAMPLE UPDATE END ************/ + /***********************************************/ + }, + }); + + return { + ...hydrogenContext, + // declare additional Remix loader context + }; +} diff --git a/examples/custom-cart-method/env.d.ts b/examples/custom-cart-method/env.d.ts deleted file mode 100644 index 0dd49a570b..0000000000 --- a/examples/custom-cart-method/env.d.ts +++ /dev/null @@ -1,70 +0,0 @@ -/// -/// -/// - -// Enhance TypeScript's built-in typings. -import '@total-typescript/ts-reset'; - -import type { - Storefront, - CustomerAccount, - HydrogenCart, - HydrogenCartCustom, - HydrogenSessionData, - CartQueryDataReturn, -} from '@shopify/hydrogen'; -import type { - SelectedOptionInput, - CartLineUpdateInput, -} from '@shopify/hydrogen/storefront-api-types'; -import type {AppSession} from '~/lib/session'; - -declare global { - /** - * A global `process` object is only available during build to access NODE_ENV. - */ - const process: {env: {NODE_ENV: 'production' | 'development'}}; - - /** - * Declare expected Env parameter in fetch handler. - */ - interface Env { - SESSION_SECRET: string; - PUBLIC_STOREFRONT_API_TOKEN: string; - PRIVATE_STOREFRONT_API_TOKEN: string; - PUBLIC_STORE_DOMAIN: string; - PUBLIC_STOREFRONT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_URL: string; - PUBLIC_CHECKOUT_DOMAIN: string; - } -} - -declare module '@shopify/remix-oxygen' { - /** - * Declare local additions to the Remix loader context. - */ - interface AppLoadContext { - env: Env; - /***********************************************/ - /********** EXAMPLE UPDATE STARTS ************/ - cart: HydrogenCartCustom<{ - updateLineByOptions: ( - productId: string, - selectedOptions: SelectedOptionInput[], - line: CartLineUpdateInput, - ) => Promise; - }>; - /********** EXAMPLE UPDATE END ************/ - /***********************************************/ - storefront: Storefront; - customerAccount: CustomerAccount; - session: AppSession; - waitUntil: ExecutionContext['waitUntil']; - } - - /** - * Declare local additions to the Remix session data. - */ - interface SessionData extends HydrogenSessionData {} -} diff --git a/examples/custom-cart-method/server.ts b/examples/custom-cart-method/server.ts deleted file mode 100644 index 4eae76427a..0000000000 --- a/examples/custom-cart-method/server.ts +++ /dev/null @@ -1,155 +0,0 @@ -// @ts-ignore -// Virtual entry point for the app -import * as remixBuild from 'virtual:remix/server-build'; -import { - cartGetIdDefault, - cartSetIdDefault, - createCartHandler, - createStorefrontClient, - storefrontRedirect, - createCustomerAccountClient, - cartLinesUpdateDefault, -} from '@shopify/hydrogen'; -import { - createRequestHandler, - getStorefrontHeaders, - type AppLoadContext, -} from '@shopify/remix-oxygen'; -import {AppSession} from '~/lib/session'; -import {CART_QUERY_FRAGMENT, PRODUCT_VARIANT_QUERY} from '~/lib/fragments'; -import type { - SelectedOptionInput, - CartLineUpdateInput, -} from '@shopify/hydrogen/storefront-api-types'; - -/** - * Export a fetch handler in module format. - */ -export default { - async fetch( - request: Request, - env: Env, - executionContext: ExecutionContext, - ): Promise { - try { - /** - * Open a cache instance in the worker and a custom session instance. - */ - if (!env?.SESSION_SECRET) { - throw new Error('SESSION_SECRET environment variable is not set'); - } - - const waitUntil = executionContext.waitUntil.bind(executionContext); - const [cache, session] = await Promise.all([ - caches.open('hydrogen'), - AppSession.init(request, [env.SESSION_SECRET]), - ]); - - /** - * Create Hydrogen's Storefront client. - */ - const {storefront} = createStorefrontClient({ - cache, - waitUntil, - i18n: {language: 'EN', country: 'US'}, - publicStorefrontToken: env.PUBLIC_STOREFRONT_API_TOKEN, - privateStorefrontToken: env.PRIVATE_STOREFRONT_API_TOKEN, - storeDomain: env.PUBLIC_STORE_DOMAIN, - storefrontId: env.PUBLIC_STOREFRONT_ID, - storefrontHeaders: getStorefrontHeaders(request), - }); - - /** - * Create a client for Customer Account API. - */ - const customerAccount = createCustomerAccountClient({ - waitUntil, - request, - session, - customerAccountId: env.PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID, - customerAccountUrl: env.PUBLIC_CUSTOMER_ACCOUNT_API_URL, - }); - - /***********************************************/ - /********** EXAMPLE UPDATE STARTS ************/ - - /* - * Create a cart handler that will be used to - * create and update the cart in the session. - */ - const getCartId = cartGetIdDefault(request.headers); - - const cartQueryOptions = { - storefront, - getCartId, - }; - - const cart = createCartHandler({ - storefront, - getCartId, - setCartId: cartSetIdDefault(), - cartQueryFragment: CART_QUERY_FRAGMENT, - customMethods: { - updateLineByOptions: async ( - productId: string, - selectedOptions: SelectedOptionInput[], - line: CartLineUpdateInput, - ) => { - const {product} = await storefront.query(PRODUCT_VARIANT_QUERY, { - variables: { - productId, - selectedOptions, - }, - }); - - const lines = [ - {...line, merchandiseId: product?.selectedVariant?.id}, - ]; - - return await cartLinesUpdateDefault(cartQueryOptions)(lines); - }, - }, - }); - /********** EXAMPLE UPDATE END ************/ - /***********************************************/ - - /** - * Create a Remix request handler and pass - * Hydrogen's Storefront client to the loader context. - */ - const handleRequest = createRequestHandler({ - build: remixBuild, - mode: process.env.NODE_ENV, - getLoadContext: (): AppLoadContext => ({ - session, - storefront, - customerAccount, - cart, - env, - waitUntil, - }), - }); - - const response = await handleRequest(request); - - if (session.isPending) { - response.headers.set('Set-Cookie', await session.commit()); - } - - if (response.status === 404) { - /** - * Check for redirects only when there's a 404 from the app. - * If the redirect doesn't exist, then `storefrontRedirect` - * will pass through the 404 response. - */ - return storefrontRedirect({request, response, storefront}); - } - - return response; - } catch (error) { - // eslint-disable-next-line no-console - console.error(error); - return new Response('An unexpected error occurred', {status: 500}); - } - }, -}; diff --git a/examples/custom-cart-method/tsconfig.json b/examples/custom-cart-method/tsconfig.json index 110d781eea..5b672cc6e1 100644 --- a/examples/custom-cart-method/tsconfig.json +++ b/examples/custom-cart-method/tsconfig.json @@ -1,6 +1,11 @@ { "extends": "../../templates/skeleton/tsconfig.json", - "include": ["./**/*.d.ts", "./**/*.ts", "./**/*.tsx"], + "include": [ + "./**/*.d.ts", + "./**/*.ts", + "./**/*.tsx", + "../../templates/skeleton/*.d.ts" + ], "compilerOptions": { "baseUrl": ".", "paths": { diff --git a/examples/express/env.d.ts b/examples/express/env.d.ts index 862f7beb2c..a44b3ca13c 100644 --- a/examples/express/env.d.ts +++ b/examples/express/env.d.ts @@ -6,9 +6,8 @@ import '@total-typescript/ts-reset'; import type { Storefront, - CustomerAccount, - HydrogenCart, HydrogenSessionData, + HydrogenEnv, } from '@shopify/hydrogen'; import type {AppSession} from '~/lib/session'; @@ -18,18 +17,8 @@ declare global { */ const process: {env: {NODE_ENV: 'production' | 'development'}}; - /** - * Declare expected Env parameter in fetch handler. - */ - interface Env { - SESSION_SECRET: string; - PUBLIC_STOREFRONT_API_TOKEN: string; - PRIVATE_STOREFRONT_API_TOKEN: string; - PUBLIC_STORE_DOMAIN: string; - PUBLIC_STOREFRONT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_URL: string; - PUBLIC_CHECKOUT_DOMAIN: string; + interface Env extends HydrogenEnv { + // declare additional Env parameter use in the fetch handler and Remix loader context here } } @@ -43,8 +32,7 @@ declare module '@remix-run/node' { session: AppSession; } - /** - * Declare local additions to the Remix session data. - */ - interface SessionData extends HydrogenSessionData {} + interface SessionData extends HydrogenSessionData { + // declare local additions to the Remix session data here + } } diff --git a/examples/legacy-customer-account-flow/env.d.ts b/examples/legacy-customer-account-flow/env.d.ts index e59ab9dafc..d299f36c25 100644 --- a/examples/legacy-customer-account-flow/env.d.ts +++ b/examples/legacy-customer-account-flow/env.d.ts @@ -5,9 +5,12 @@ // Enhance TypeScript's built-in typings. import '@total-typescript/ts-reset'; -import type {Storefront, HydrogenCart} from '@shopify/hydrogen'; -import type {CustomerAccessToken} from '@shopify/hydrogen/storefront-api-types'; -import type {AppSession} from '~/lib/session'; +import type { + HydrogenContext, + HydrogenSessionData, + HydrogenEnv, +} from '@shopify/hydrogen'; +import type {createAppLoadContext} from '~/lib/context'; declare global { /** @@ -15,35 +18,23 @@ declare global { */ const process: {env: {NODE_ENV: 'production' | 'development'}}; - /** - * Declare expected Env parameter in fetch handler. - */ - interface Env { - SESSION_SECRET: string; - PUBLIC_STOREFRONT_API_TOKEN: string; - PRIVATE_STOREFRONT_API_TOKEN: string; - PUBLIC_STORE_DOMAIN: string; - PUBLIC_STOREFRONT_ID: string; - PUBLIC_CHECKOUT_DOMAIN: string; + interface Env extends HydrogenEnv { + // declare additional Env parameter use in the fetch handler and Remix loader context here } } declare module '@shopify/remix-oxygen' { - /** - * Declare local additions to the Remix loader context. - */ - export interface AppLoadContext { - env: Env; - cart: HydrogenCart; - storefront: Storefront; - session: AppSession; - waitUntil: ExecutionContext['waitUntil']; + interface AppLoadContext + extends Awaited> { + // to change context type, change the return of createAppLoadContext() instead } - /** - * Declare the data we expect to access via `context.session`. - */ - export interface SessionData { + /***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/ + interface SessionData { + // declare local additions to the Remix session data here customerAccessToken: CustomerAccessToken; } + /********** EXAMPLE UPDATE END ************/ + /***********************************************/ } diff --git a/examples/legacy-customer-account-flow/server.ts b/examples/legacy-customer-account-flow/server.ts deleted file mode 100644 index c9116e05ed..0000000000 --- a/examples/legacy-customer-account-flow/server.ts +++ /dev/null @@ -1,105 +0,0 @@ -// @ts-ignore -// Virtual entry point for the app -import * as remixBuild from 'virtual:remix/server-build'; -import { - cartGetIdDefault, - cartSetIdDefault, - createCartHandler, - createStorefrontClient, - storefrontRedirect, -} from '@shopify/hydrogen'; -import { - createRequestHandler, - getStorefrontHeaders, - type AppLoadContext, -} from '@shopify/remix-oxygen'; -import {AppSession} from '~/lib/session'; -import {CART_QUERY_FRAGMENT} from '~/lib/fragments'; - -/** - * Export a fetch handler in module format. - */ -export default { - async fetch( - request: Request, - env: Env, - executionContext: ExecutionContext, - ): Promise { - try { - /** - * Open a cache instance in the worker and a custom session instance. - */ - if (!env?.SESSION_SECRET) { - throw new Error('SESSION_SECRET environment variable is not set'); - } - - const waitUntil = executionContext.waitUntil.bind(executionContext); - const [cache, session] = await Promise.all([ - caches.open('hydrogen'), - AppSession.init(request, [env.SESSION_SECRET]), - ]); - - /** - * Create Hydrogen's Storefront client. - */ - const {storefront} = createStorefrontClient({ - cache, - waitUntil, - i18n: {language: 'EN', country: 'US'}, - publicStorefrontToken: env.PUBLIC_STOREFRONT_API_TOKEN, - privateStorefrontToken: env.PRIVATE_STOREFRONT_API_TOKEN, - storeDomain: env.PUBLIC_STORE_DOMAIN, - storefrontId: env.PUBLIC_STOREFRONT_ID, - storefrontHeaders: getStorefrontHeaders(request), - }); - - /* - * Create a cart handler that will be used to - * create and update the cart in the session. - */ - const cart = createCartHandler({ - storefront, - getCartId: cartGetIdDefault(request.headers), - setCartId: cartSetIdDefault(), - cartQueryFragment: CART_QUERY_FRAGMENT, - }); - - /** - * Create a Remix request handler and pass - * Hydrogen's Storefront client to the loader context. - */ - const handleRequest = createRequestHandler({ - build: remixBuild, - mode: process.env.NODE_ENV, - getLoadContext: (): AppLoadContext => ({ - session, - storefront, - cart, - env, - waitUntil, - }), - }); - - const response = await handleRequest(request); - - if (session.isPending) { - response.headers.set('Set-Cookie', await session.commit()); - } - - if (response.status === 404) { - /** - * Check for redirects only when there's a 404 from the app. - * If the redirect doesn't exist, then `storefrontRedirect` - * will pass through the 404 response. - */ - return storefrontRedirect({request, response, storefront}); - } - - return response; - } catch (error) { - // eslint-disable-next-line no-console - console.error(error); - return new Response('An unexpected error occurred', {status: 500}); - } - }, -}; diff --git a/examples/metaobjects/env.d.ts b/examples/metaobjects/env.d.ts index 351bfe5f85..d7d2089163 100644 --- a/examples/metaobjects/env.d.ts +++ b/examples/metaobjects/env.d.ts @@ -6,12 +6,11 @@ import '@total-typescript/ts-reset'; import type { - Storefront, - CustomerAccount, - HydrogenCart, + HydrogenContext, HydrogenSessionData, + HydrogenEnv, } from '@shopify/hydrogen'; -import type {AppSession} from '~/lib/session'; +import type {createAppLoadContext} from '~/lib/context'; declare global { /** @@ -19,18 +18,8 @@ declare global { */ const process: {env: {NODE_ENV: 'production' | 'development'}}; - /** - * Declare expected Env parameter in fetch handler. - */ - interface Env { - SESSION_SECRET: string; - PUBLIC_STOREFRONT_API_TOKEN: string; - PRIVATE_STOREFRONT_API_TOKEN: string; - PUBLIC_STORE_DOMAIN: string; - PUBLIC_STOREFRONT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_URL: string; - PUBLIC_CHECKOUT_DOMAIN: string; + interface Env extends HydrogenEnv { + // declare additional Env parameter use in the fetch handler and Remix loader context here /***********************************************/ /********** EXAMPLE UPDATE STARTS ************/ PUBLIC_SHOPIFY_STORE_DOMAIN: string; @@ -40,20 +29,12 @@ declare global { } declare module '@shopify/remix-oxygen' { - /** - * Declare local additions to the Remix loader context. - */ - interface AppLoadContext { - env: Env; - cart: HydrogenCart; - storefront: Storefront; - customerAccount: CustomerAccount; - session: AppSession; - waitUntil: ExecutionContext['waitUntil']; + interface AppLoadContext + extends Awaited> { + // to change context type, change the return of createAppLoadContext() instead } - /** - * Declare local additions to the Remix session data. - */ - interface SessionData extends HydrogenSessionData {} + interface SessionData extends HydrogenSessionData { + // declare local additions to the Remix session data here + } } diff --git a/examples/multipass/env.d.ts b/examples/multipass/env.d.ts index 6a84c4a938..e485dd7f82 100644 --- a/examples/multipass/env.d.ts +++ b/examples/multipass/env.d.ts @@ -6,12 +6,11 @@ import '@total-typescript/ts-reset'; import type { - Storefront, - CustomerAccount, - HydrogenCart, + HydrogenContext, HydrogenSessionData, + HydrogenEnv, } from '@shopify/hydrogen'; -import type {AppSession} from '~/lib/session'; +import type {createAppLoadContext} from '~/lib/context'; declare global { /** @@ -19,18 +18,8 @@ declare global { */ const process: {env: {NODE_ENV: 'production' | 'development'}}; - /** - * Declare expected Env parameter in fetch handler. - */ - interface Env { - SESSION_SECRET: string; - PUBLIC_STOREFRONT_API_TOKEN: string; - PRIVATE_STOREFRONT_API_TOKEN: string; - PUBLIC_STORE_DOMAIN: string; - PUBLIC_STOREFRONT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_URL: string; - PUBLIC_CHECKOUT_DOMAIN: string; + interface Env extends HydrogenEnv { + // declare additional Env parameter in the fetch handler and in Remix loader context here /***********************************************/ /********** EXAMPLE UPDATE STARTS ************/ PRIVATE_SHOPIFY_STORE_MULTIPASS_SECRET: string; @@ -41,23 +30,15 @@ declare global { } declare module '@shopify/remix-oxygen' { - /** - * Declare local additions to the Remix loader context. - */ - interface AppLoadContext { - env: Env; - cart: HydrogenCart; - storefront: Storefront; - session: AppSession; - waitUntil: ExecutionContext['waitUntil']; + interface AppLoadContext + extends Awaited> { + // to change context type, change the return of createAppLoadContext() instead } - /** - * Declare the data we expect to access via `context.session`. - */ /***********************************************/ /********** EXAMPLE UPDATE STARTS ************/ - export interface SessionData { + interface SessionData { + // declare local additions to the Remix session data here customerAccessToken: CustomerAccessToken; } /********** EXAMPLE UPDATE END ************/ diff --git a/examples/multipass/server.ts b/examples/multipass/server.ts deleted file mode 100644 index c9116e05ed..0000000000 --- a/examples/multipass/server.ts +++ /dev/null @@ -1,105 +0,0 @@ -// @ts-ignore -// Virtual entry point for the app -import * as remixBuild from 'virtual:remix/server-build'; -import { - cartGetIdDefault, - cartSetIdDefault, - createCartHandler, - createStorefrontClient, - storefrontRedirect, -} from '@shopify/hydrogen'; -import { - createRequestHandler, - getStorefrontHeaders, - type AppLoadContext, -} from '@shopify/remix-oxygen'; -import {AppSession} from '~/lib/session'; -import {CART_QUERY_FRAGMENT} from '~/lib/fragments'; - -/** - * Export a fetch handler in module format. - */ -export default { - async fetch( - request: Request, - env: Env, - executionContext: ExecutionContext, - ): Promise { - try { - /** - * Open a cache instance in the worker and a custom session instance. - */ - if (!env?.SESSION_SECRET) { - throw new Error('SESSION_SECRET environment variable is not set'); - } - - const waitUntil = executionContext.waitUntil.bind(executionContext); - const [cache, session] = await Promise.all([ - caches.open('hydrogen'), - AppSession.init(request, [env.SESSION_SECRET]), - ]); - - /** - * Create Hydrogen's Storefront client. - */ - const {storefront} = createStorefrontClient({ - cache, - waitUntil, - i18n: {language: 'EN', country: 'US'}, - publicStorefrontToken: env.PUBLIC_STOREFRONT_API_TOKEN, - privateStorefrontToken: env.PRIVATE_STOREFRONT_API_TOKEN, - storeDomain: env.PUBLIC_STORE_DOMAIN, - storefrontId: env.PUBLIC_STOREFRONT_ID, - storefrontHeaders: getStorefrontHeaders(request), - }); - - /* - * Create a cart handler that will be used to - * create and update the cart in the session. - */ - const cart = createCartHandler({ - storefront, - getCartId: cartGetIdDefault(request.headers), - setCartId: cartSetIdDefault(), - cartQueryFragment: CART_QUERY_FRAGMENT, - }); - - /** - * Create a Remix request handler and pass - * Hydrogen's Storefront client to the loader context. - */ - const handleRequest = createRequestHandler({ - build: remixBuild, - mode: process.env.NODE_ENV, - getLoadContext: (): AppLoadContext => ({ - session, - storefront, - cart, - env, - waitUntil, - }), - }); - - const response = await handleRequest(request); - - if (session.isPending) { - response.headers.set('Set-Cookie', await session.commit()); - } - - if (response.status === 404) { - /** - * Check for redirects only when there's a 404 from the app. - * If the redirect doesn't exist, then `storefrontRedirect` - * will pass through the 404 response. - */ - return storefrontRedirect({request, response, storefront}); - } - - return response; - } catch (error) { - // eslint-disable-next-line no-console - console.error(error); - return new Response('An unexpected error occurred', {status: 500}); - } - }, -}; diff --git a/examples/partytown/README.md b/examples/partytown/README.md index 61164d206a..f2def26664 100644 --- a/examples/partytown/README.md +++ b/examples/partytown/README.md @@ -214,12 +214,7 @@ declare global { /** * Declare expected Env parameter in fetch handler. */ - interface Env { - SESSION_SECRET: string; - PUBLIC_STOREFRONT_API_TOKEN: string; - PRIVATE_STOREFRONT_API_TOKEN: string; - PUBLIC_STORE_DOMAIN: string; - PUBLIC_STOREFRONT_ID: string; + interface Env extends HydrogenEnv { + GTM_CONTAINER_ID: `GTM-${string}`; } } diff --git a/examples/partytown/env.d.ts b/examples/partytown/env.d.ts index 8072737e35..5c76c8ac12 100644 --- a/examples/partytown/env.d.ts +++ b/examples/partytown/env.d.ts @@ -6,12 +6,11 @@ import '@total-typescript/ts-reset'; import type { - Storefront, - CustomerAccount, - HydrogenCart, + HydrogenContext, HydrogenSessionData, + HydrogenEnv, } from '@shopify/hydrogen'; -import type {AppSession} from '~/lib/session'; +import type {createAppLoadContext} from '~/lib/context'; declare global { /** @@ -19,18 +18,8 @@ declare global { */ const process: {env: {NODE_ENV: 'production' | 'development'}}; - /** - * Declare expected Env parameter in fetch handler. - */ - interface Env { - SESSION_SECRET: string; - PUBLIC_STOREFRONT_API_TOKEN: string; - PRIVATE_STOREFRONT_API_TOKEN: string; - PUBLIC_STORE_DOMAIN: string; - PUBLIC_STOREFRONT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_URL: string; - PUBLIC_CHECKOUT_DOMAIN: string; + interface Env extends HydrogenEnv { + // declare additional Env parameter in the fetch handler and in Remix loader context here /***********************************************/ /********** EXAMPLE UPDATE STARTS ************/ GTM_CONTAINER_ID: `GTM-${string}`; @@ -40,20 +29,12 @@ declare global { } declare module '@shopify/remix-oxygen' { - /** - * Declare local additions to the Remix loader context. - */ - interface AppLoadContext { - env: Env; - cart: HydrogenCart; - storefront: Storefront; - customerAccount: CustomerAccount; - session: AppSession; - waitUntil: ExecutionContext['waitUntil']; + interface AppLoadContext + extends Awaited> { + // to change context type, change the return of createAppLoadContext() instead } - /** - * Declare local additions to the Remix session data. - */ - interface SessionData extends HydrogenSessionData {} + interface SessionData extends HydrogenSessionData { + // declare local additions to the Remix session data here + } } diff --git a/examples/third-party-queries-caching/app/lib/context.ts b/examples/third-party-queries-caching/app/lib/context.ts new file mode 100644 index 0000000000..8c5baa5584 --- /dev/null +++ b/examples/third-party-queries-caching/app/lib/context.ts @@ -0,0 +1,67 @@ +import {createHydrogenContext} from '@shopify/hydrogen'; +import {AppSession} from '~/lib/session'; +import {CART_QUERY_FRAGMENT} from '~/lib/fragments'; +/***********************************************/ +/********** EXAMPLE UPDATE STARTS ************/ +// 1. Import the Rick and Morty client. +import {createRickAndMortyClient} from '~/lib/createRickAndMortyClient.server'; +/********** EXAMPLE UPDATE END ************/ +/***********************************************/ + +/** + * The context implementation is separate from server.ts + * so that type can be extracted for AppLoadContext + * */ +export async function createAppLoadContext( + request: Request, + env: Env, + executionContext: ExecutionContext, +) { + /** + * Open a cache instance in the worker and a custom session instance. + */ + if (!env?.SESSION_SECRET) { + throw new Error('SESSION_SECRET environment variable is not set'); + } + + const waitUntil = executionContext.waitUntil.bind(executionContext); + const [cache, session] = await Promise.all([ + caches.open('hydrogen'), + AppSession.init(request, [env.SESSION_SECRET]), + ]); + + const hydrogenContext = createHydrogenContext({ + env, + request, + cache, + waitUntil, + session, + i18n: {language: 'EN', country: 'US'}, + cart: { + queryFragment: CART_QUERY_FRAGMENT, + }, + }); + + /***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/ + /** + * 2. Create a Rick and Morty client. + */ + const rickAndMorty = createRickAndMortyClient({ + cache, + waitUntil, + request, + }); + /********** EXAMPLE UPDATE END ************/ + /***********************************************/ + + return { + ...hydrogenContext, + /***********************************************/ + /********** EXAMPLE UPDATE STARTS ************/ + rickAndMorty, // 3. Pass the Rick and Morty client to the action and loader context. + /********** EXAMPLE UPDATE END ************/ + /***********************************************/ + // declare additional Remix loader context + }; +} diff --git a/examples/third-party-queries-caching/env.d.ts b/examples/third-party-queries-caching/env.d.ts deleted file mode 100644 index f0bad47e63..0000000000 --- a/examples/third-party-queries-caching/env.d.ts +++ /dev/null @@ -1,56 +0,0 @@ -/// -/// -/// - -// Enhance TypeScript's built-in typings. -import '@total-typescript/ts-reset'; - -import type { - Storefront, - CustomerAccount, - HydrogenCart, - HydrogenSessionData, -} from '@shopify/hydrogen'; -import type {AppSession} from '~/lib/session'; -import {createRickAndMortyClient} from './app/lib/createRickAndMortyClient.server'; - -declare global { - /** - * A global `process` object is only available during build to access NODE_ENV. - */ - const process: {env: {NODE_ENV: 'production' | 'development'}}; - - /** - * Declare expected Env parameter in fetch handler. - */ - interface Env { - SESSION_SECRET: string; - PUBLIC_STOREFRONT_API_TOKEN: string; - PRIVATE_STOREFRONT_API_TOKEN: string; - PUBLIC_STORE_DOMAIN: string; - PUBLIC_STOREFRONT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_URL: string; - PUBLIC_CHECKOUT_DOMAIN: string; - } -} - -declare module '@shopify/remix-oxygen' { - /** - * Declare local additions to the Remix loader context. - */ - export interface AppLoadContext { - env: Env; - cart: HydrogenCart; - storefront: Storefront; - customerAccount: CustomerAccount; - rickAndMorty: ReturnType; - session: AppSession; - waitUntil: ExecutionContext['waitUntil']; - } - - /** - * Declare local additions to the Remix session data. - */ - interface SessionData extends HydrogenSessionData {} -} diff --git a/examples/third-party-queries-caching/server.ts b/examples/third-party-queries-caching/server.ts deleted file mode 100644 index 8b321527d4..0000000000 --- a/examples/third-party-queries-caching/server.ts +++ /dev/null @@ -1,132 +0,0 @@ -// @ts-ignore -// Virtual entry point for the app -import * as remixBuild from 'virtual:remix/server-build'; -import { - cartGetIdDefault, - cartSetIdDefault, - createCartHandler, - createStorefrontClient, - storefrontRedirect, - createCustomerAccountClient, -} from '@shopify/hydrogen'; -import { - createRequestHandler, - getStorefrontHeaders, - type AppLoadContext, -} from '@shopify/remix-oxygen'; -import {AppSession} from '~/lib/session'; -import {CART_QUERY_FRAGMENT} from '~/lib/fragments'; - -// 1. Import the Rick and Morty client. -import {createRickAndMortyClient} from './app/lib/createRickAndMortyClient.server'; - -/** - * Export a fetch handler in module format. - */ -export default { - async fetch( - request: Request, - env: Env, - executionContext: ExecutionContext, - ): Promise { - try { - /** - * Open a cache instance in the worker and a custom session instance. - */ - if (!env?.SESSION_SECRET) { - throw new Error('SESSION_SECRET environment variable is not set'); - } - - const waitUntil = executionContext.waitUntil.bind(executionContext); - - const [cache, session] = await Promise.all([ - caches.open('hydrogen'), - AppSession.init(request, [env.SESSION_SECRET]), - ]); - - /** - * Create Hydrogen's Storefront client. - */ - const {storefront} = createStorefrontClient({ - cache, - waitUntil, - i18n: {language: 'EN', country: 'US'}, - publicStorefrontToken: env.PUBLIC_STOREFRONT_API_TOKEN, - privateStorefrontToken: env.PRIVATE_STOREFRONT_API_TOKEN, - storeDomain: env.PUBLIC_STORE_DOMAIN, - storefrontId: env.PUBLIC_STOREFRONT_ID, - storefrontHeaders: getStorefrontHeaders(request), - }); - - /** - * Create a client for Customer Account API. - */ - const customerAccount = createCustomerAccountClient({ - waitUntil, - request, - session, - customerAccountId: env.PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID, - customerAccountUrl: env.PUBLIC_CUSTOMER_ACCOUNT_API_URL, - }); - - /** - * 2. Create a Rick and Morty client. - */ - const rickAndMorty = createRickAndMortyClient({ - cache, - waitUntil, - request, - }); - - /* - * Create a cart handler that will be used to - * create and update the cart in the session. - */ - const cart = createCartHandler({ - storefront, - getCartId: cartGetIdDefault(request.headers), - setCartId: cartSetIdDefault(), - cartQueryFragment: CART_QUERY_FRAGMENT, - }); - - /** - * Create a Remix request handler and pass - * Hydrogen's Storefront client to the loader context. - */ - const handleRequest = createRequestHandler({ - build: remixBuild, - mode: process.env.NODE_ENV, - getLoadContext: (): AppLoadContext => ({ - session, - storefront, - customerAccount, - cart, - env, - waitUntil, - rickAndMorty, // 3. Pass the Rick and Morty client to the action and loader context. - }), - }); - - const response = await handleRequest(request); - - if (session.isPending) { - response.headers.set('Set-Cookie', await session.commit()); - } - - if (response.status === 404) { - /** - * Check for redirects only when there's a 404 from the app. - * If the redirect doesn't exist, then `storefrontRedirect` - * will pass through the 404 response. - */ - return storefrontRedirect({request, response, storefront}); - } - - return response; - } catch (error) { - // eslint-disable-next-line no-console - console.error(error); - return new Response('An unexpected error occurred', {status: 500}); - } - }, -}; diff --git a/examples/third-party-queries-caching/tsconfig.json b/examples/third-party-queries-caching/tsconfig.json index 110d781eea..5b672cc6e1 100644 --- a/examples/third-party-queries-caching/tsconfig.json +++ b/examples/third-party-queries-caching/tsconfig.json @@ -1,6 +1,11 @@ { "extends": "../../templates/skeleton/tsconfig.json", - "include": ["./**/*.d.ts", "./**/*.ts", "./**/*.tsx"], + "include": [ + "./**/*.d.ts", + "./**/*.ts", + "./**/*.tsx", + "../../templates/skeleton/*.d.ts" + ], "compilerOptions": { "baseUrl": ".", "paths": { diff --git a/packages/cli/assets/i18n/domains.ts b/packages/cli/assets/i18n/domains.ts index bb6c48bd90..db49a5ddfd 100644 --- a/packages/cli/assets/i18n/domains.ts +++ b/packages/cli/assets/i18n/domains.ts @@ -1,18 +1,13 @@ -import type {LanguageCode, CountryCode} from './mock-i18n-types.js'; +import type {I18nBase} from './mock-i18n-types.js'; -export type I18nLocale = {language: LanguageCode; country: CountryCode}; - -/** - * @returns {I18nLocale} - */ -function getLocaleFromRequest(request: Request): I18nLocale { - const defaultLocale: I18nLocale = {language: 'EN', country: 'US'}; +export function getLocaleFromRequest(request: Request): I18nBase { + const defaultLocale: I18nBase = {language: 'EN', country: 'US'}; const supportedLocales = { ES: 'ES', FR: 'FR', DE: 'DE', JP: 'JA', - } as Record; + } as Record; const url = new URL(request.url); const domain = url.hostname @@ -24,5 +19,3 @@ function getLocaleFromRequest(request: Request): I18nLocale { ? {language: supportedLocales[domain], country: domain} : defaultLocale; } - -export {getLocaleFromRequest}; diff --git a/packages/cli/assets/i18n/mock-i18n-types.ts b/packages/cli/assets/i18n/mock-i18n-types.ts index c3ed3a12f3..0a4a3abb5c 100644 --- a/packages/cli/assets/i18n/mock-i18n-types.ts +++ b/packages/cli/assets/i18n/mock-i18n-types.ts @@ -1,3 +1,5 @@ // Mock types so we don't need to depend on Hydrogen React -export type CountryCode = 'US' | 'ES' | 'FR' | 'DE' | 'JP'; -export type LanguageCode = 'EN' | 'ES' | 'FR' | 'DE' | 'JA'; +export type I18nBase = { + language: 'EN' | 'ES' | 'FR' | 'DE' | 'JA'; + country: 'US' | 'ES' | 'FR' | 'DE' | 'JP'; +}; diff --git a/packages/cli/assets/i18n/subdomains.ts b/packages/cli/assets/i18n/subdomains.ts index a9f2a75d0b..da34bd2125 100644 --- a/packages/cli/assets/i18n/subdomains.ts +++ b/packages/cli/assets/i18n/subdomains.ts @@ -1,18 +1,13 @@ -import type {LanguageCode, CountryCode} from './mock-i18n-types.js'; +import type {I18nBase} from './mock-i18n-types.js'; -export type I18nLocale = {language: LanguageCode; country: CountryCode}; - -/** - * @returns {I18nLocale} - */ -function getLocaleFromRequest(request: Request): I18nLocale { - const defaultLocale: I18nLocale = {language: 'EN', country: 'US'}; +export function getLocaleFromRequest(request: Request): I18nBase { + const defaultLocale: I18nBase = {language: 'EN', country: 'US'}; const supportedLocales = { ES: 'ES', FR: 'FR', DE: 'DE', JP: 'JA', - } as Record; + } as Record; const url = new URL(request.url); const firstSubdomain = url.hostname @@ -23,5 +18,3 @@ function getLocaleFromRequest(request: Request): I18nLocale { ? {language: supportedLocales[firstSubdomain], country: firstSubdomain} : defaultLocale; } - -export {getLocaleFromRequest}; diff --git a/packages/cli/assets/i18n/subfolders.ts b/packages/cli/assets/i18n/subfolders.ts index 6f856c9d55..4585b1082c 100644 --- a/packages/cli/assets/i18n/subfolders.ts +++ b/packages/cli/assets/i18n/subfolders.ts @@ -1,15 +1,10 @@ -import type {LanguageCode, CountryCode} from './mock-i18n-types.js'; +import type {I18nBase} from './mock-i18n-types.js'; -export type I18nLocale = { - language: LanguageCode; - country: CountryCode; +export interface I18nLocale extends I18nBase { pathPrefix: string; -}; +} -/** - * @returns {I18nLocale} - */ -function getLocaleFromRequest(request: Request): I18nLocale { +export function getLocaleFromRequest(request: Request): I18nLocale { const url = new URL(request.url); const firstPathPart = url.pathname.split('/')[1]?.toUpperCase() ?? ''; @@ -25,5 +20,3 @@ function getLocaleFromRequest(request: Request): I18nLocale { return {language, country, pathPrefix}; } - -export {getLocaleFromRequest}; diff --git a/packages/cli/dev.env.d.ts b/packages/cli/dev.env.d.ts index 42a52626b4..6c51db0810 100644 --- a/packages/cli/dev.env.d.ts +++ b/packages/cli/dev.env.d.ts @@ -2,11 +2,7 @@ * This file is used to provide types for generator utilities. */ -import type { - Storefront, - CustomerAccount, - HydrogenCart, -} from '@shopify/hydrogen'; +import type {HydrogenContext, HydrogenSession} from '@shopify/hydrogen'; import type { LanguageCode, CountryCode, @@ -16,13 +12,8 @@ declare module '@shopify/remix-oxygen' { /** * Declare local additions to the Remix loader context. */ - export interface AppLoadContext { - env: Env; - cart: HydrogenCart; - storefront: Storefront; - customerAccount: CustomerAccount; - waitUntil: ExecutionContext['waitUntil']; - } + export interface AppLoadContext + extends HydrogenContext {} } declare global { diff --git a/packages/cli/src/commands/hydrogen/setup.test.ts b/packages/cli/src/commands/hydrogen/setup.test.ts index 1943bf588d..2a9b7f32f6 100644 --- a/packages/cli/src/commands/hydrogen/setup.test.ts +++ b/packages/cli/src/commands/hydrogen/setup.test.ts @@ -72,9 +72,11 @@ describe('setup', () => { fileExists(joinPath(tmpDir, 'app/routes/($locale)._index.tsx')), ).resolves.toBeTruthy(); - const serverFile = await readFile(`${tmpDir}/server.ts`); - expect(serverFile).toMatch(/i18n: getLocaleFromRequest\(request\),/); - expect(serverFile).toMatch(/url.pathname/); + const contextFile = await readFile(`${tmpDir}/app/lib/context.ts`); + expect(contextFile).toMatch(/i18n: getLocaleFromRequest\(request\),/); + + const i18nFile = await readFile(`${tmpDir}/app/lib/i18n.ts`); + expect(i18nFile).toMatch(/url.pathname/); const output = outputMock.info(); expect(output).toMatch('success'); diff --git a/packages/cli/src/commands/hydrogen/setup.ts b/packages/cli/src/commands/hydrogen/setup.ts index 6b94f68cc4..5844f0db2d 100644 --- a/packages/cli/src/commands/hydrogen/setup.ts +++ b/packages/cli/src/commands/hydrogen/setup.ts @@ -56,9 +56,7 @@ type RunSetupOptions = { export async function runSetup(options: RunSetupOptions) { const controller = new AbortController(); - const {rootDirectory, appDirectory, serverEntryPoint} = await getRemixConfig( - options.directory, - ); + const {rootDirectory, appDirectory} = await getRemixConfig(options.directory); const location = basename(rootDirectory); const cliCommandPromise = getCliCommand(); @@ -129,7 +127,7 @@ export async function runSetup(options: RunSetupOptions) { // i18n setup needs to happen after copying the app entries, // because it needs to modify the server entry point. backgroundWorkPromise = backgroundWorkPromise.then(() => - setupI18nStrategy(i18n, {rootDirectory, serverEntryPoint}), + setupI18nStrategy(i18n, {rootDirectory}), ); } diff --git a/packages/cli/src/lib/onboarding/local.test.ts b/packages/cli/src/lib/onboarding/local.test.ts index 4aed0fcdd2..dbd6b79794 100644 --- a/packages/cli/src/lib/onboarding/local.test.ts +++ b/packages/cli/src/lib/onboarding/local.test.ts @@ -257,9 +257,11 @@ describe('local templates', () => { expect(resultFiles).toContain('app/routes/_index.tsx'); // Injects styles in Root - const serverFile = await readFile(`${tmpDir}/server.ts`); - expect(serverFile).toMatch(/i18n: getLocaleFromRequest\(request\),/); - expect(serverFile).toMatch(/domain = url.hostname/); + const contextFile = await readFile(`${tmpDir}/app/lib/context.ts`); + expect(contextFile).toMatch(/i18n: getLocaleFromRequest\(request\),/); + + const i18nFile = await readFile(`${tmpDir}/app/lib/i18n.ts`); + expect(i18nFile).toMatch(/domain = url.hostname/); const output = outputMock.info(); expect(output).toMatch('success'); @@ -282,9 +284,11 @@ describe('local templates', () => { expect(resultFiles).toContain('app/routes/_index.tsx'); // Injects styles in Root - const serverFile = await readFile(`${tmpDir}/server.ts`); - expect(serverFile).toMatch(/i18n: getLocaleFromRequest\(request\),/); - expect(serverFile).toMatch(/firstSubdomain = url.hostname/); + const contextFile = await readFile(`${tmpDir}/app/lib/context.ts`); + expect(contextFile).toMatch(/i18n: getLocaleFromRequest\(request\),/); + + const i18nFile = await readFile(`${tmpDir}/app/lib/i18n.ts`); + expect(i18nFile).toMatch(/firstSubdomain = url.hostname/); const output = outputMock.info(); expect(output).toMatch('success'); @@ -311,9 +315,11 @@ describe('local templates', () => { expect(resultFiles).toContain('app/routes/($locale).tsx'); // Injects styles in Root - const serverFile = await readFile(`${tmpDir}/server.ts`); - expect(serverFile).toMatch(/i18n: getLocaleFromRequest\(request\),/); - expect(serverFile).toMatch(/url.pathname/); + const contextFile = await readFile(`${tmpDir}/app/lib/context.ts`); + expect(contextFile).toMatch(/i18n: getLocaleFromRequest\(request\),/); + + const i18nFile = await readFile(`${tmpDir}/app/lib/i18n.ts`); + expect(i18nFile).toMatch(/url.pathname/); const output = outputMock.info(); expect(output).toMatch('success'); diff --git a/packages/cli/src/lib/onboarding/local.ts b/packages/cli/src/lib/onboarding/local.ts index 5ad9d7d8d4..0b6b17c39a 100644 --- a/packages/cli/src/lib/onboarding/local.ts +++ b/packages/cli/src/lib/onboarding/local.ts @@ -321,7 +321,8 @@ export async function setupLocalStarterTemplate( await setupI18n({ rootDirectory: project.directory, - serverEntryPoint: language === 'ts' ? 'server.ts' : 'server.js', + contextCreate: + language === 'ts' ? 'app/lib/context.ts' : 'app/lib/context.js', }) .then(() => options.git diff --git a/packages/cli/src/lib/setups/i18n/domains.test.ts b/packages/cli/src/lib/setups/i18n/domains.test.ts index 30c094cee2..baa3bf34cc 100644 --- a/packages/cli/src/lib/setups/i18n/domains.test.ts +++ b/packages/cli/src/lib/setups/i18n/domains.test.ts @@ -1,6 +1,4 @@ import {describe, it, expect} from 'vitest'; -import {readFile} from '@shopify/cli-kit/node/fs'; -import {getAssetsDir} from '../../build.js'; import {getLocaleFromRequest} from '../../../../assets/i18n/domains.js'; describe('Setup i18n with domains', () => { @@ -24,23 +22,4 @@ describe('Setup i18n with domains', () => { country: 'ES', }); }); - - it('does not access imported types directly', async () => { - const template = await readFile(await getAssetsDir('i18n', 'domains.ts')); - - const typeImports = (template.match(/import\s+type\s+{([^}]+)}/)?.[1] || '') - .trim() - .split(/\s*,\s*/); - - expect(typeImports).not.toHaveLength(0); - - // Assert that typed imports are not accessed directly but via `I18nLocale[...]` instead. - // These types are not imported in the final file. - const fnCode = template.match(/function .*\n}$/ms)?.[0] || ''; - expect(fnCode).toBeTruthy(); - - typeImports.forEach((typeImport) => - expect(fnCode).not.toContain(typeImport), - ); - }); }); diff --git a/packages/cli/src/lib/setups/i18n/index.ts b/packages/cli/src/lib/setups/i18n/index.ts index 1a337123c8..7810ee6cf9 100644 --- a/packages/cli/src/lib/setups/i18n/index.ts +++ b/packages/cli/src/lib/setups/i18n/index.ts @@ -1,9 +1,8 @@ -import {fileURLToPath} from 'node:url'; import {renderSelectPrompt} from '@shopify/cli-kit/node/ui'; -import {fileExists, readFile} from '@shopify/cli-kit/node/fs'; +import {fileExists} from '@shopify/cli-kit/node/fs'; import {AbortSignal} from '@shopify/cli-kit/node/abort'; import {getCodeFormatOptions} from '../../format-code.js'; -import {replaceRemixEnv, replaceServerI18n} from './replacers.js'; +import {replaceContextI18n} from './replacers.js'; import {getAssetsDir} from '../../build.js'; export const SETUP_I18N_STRATEGIES = [ @@ -25,7 +24,7 @@ export type I18nChoice = (typeof I18N_CHOICES)[number]; export type I18nSetupConfig = { rootDirectory: string; - serverEntryPoint?: string; + contextCreate?: string; }; export async function setupI18nStrategy( @@ -38,12 +37,9 @@ export async function setupI18nStrategy( throw new Error('Unknown strategy'); } - const template = await readFile(templatePath); const formatConfig = await getCodeFormatOptions(options.rootDirectory); - const isJs = options.serverEntryPoint?.endsWith('.js') ?? false; - await replaceServerI18n(options, formatConfig, template, isJs); - await replaceRemixEnv(options, formatConfig, template); + await replaceContextI18n(options, formatConfig, templatePath); } export async function renderI18nPrompt< diff --git a/packages/cli/src/lib/setups/i18n/replacers.test.ts b/packages/cli/src/lib/setups/i18n/replacers.test.ts index f9eae280da..d9d967ba9b 100644 --- a/packages/cli/src/lib/setups/i18n/replacers.test.ts +++ b/packages/cli/src/lib/setups/i18n/replacers.test.ts @@ -1,19 +1,21 @@ -import {fileURLToPath} from 'node:url'; import {describe, it, expect} from 'vitest'; import { inTemporaryDirectory, - copyFile, readFile, - writeFile, + copyFile, + fileExists, } from '@shopify/cli-kit/node/fs'; -import {joinPath} from '@shopify/cli-kit/node/path'; +import {joinPath, dirname} from '@shopify/cli-kit/node/path'; import {ts} from 'ts-morph'; import {getAssetsDir, getSkeletonSourceDir} from '../../build.js'; -import {replaceRemixEnv, replaceServerI18n} from './replacers.js'; +import {replaceContextI18n} from './replacers.js'; import {DEFAULT_COMPILER_OPTIONS} from '../../transpile/morph/index.js'; -const envDts = 'env.d.ts'; -const serverTs = 'server.ts'; +const contextTs = 'app/lib/context.ts'; +const expectedI18nFileTs = 'app/lib/i18n.ts'; + +const contextJs = 'app/lib/context.js'; +const expectedI18nFileJs = 'app/lib/i18n.js'; const checkTypes = (content: string) => { const {diagnostics} = ts.transpileModule(content, { @@ -32,240 +34,105 @@ const checkTypes = (content: string) => { }; describe('i18n replacers', () => { - it('adds i18n type to remix.env.d.ts', async () => { + it('adds i18n function call to context create file', async () => { await inTemporaryDirectory(async (tmpDir) => { - const skeletonDir = getSkeletonSourceDir(); - await copyFile(joinPath(skeletonDir, envDts), joinPath(tmpDir, envDts)); + const skeletonContextFilePath = joinPath( + getSkeletonSourceDir(), + contextTs, + ); + const testContextFilePath = joinPath(tmpDir, contextTs); - await replaceRemixEnv( - {rootDirectory: tmpDir}, + await copyFile(skeletonContextFilePath, testContextFilePath); + + await replaceContextI18n( + {rootDirectory: tmpDir, contextCreate: contextTs}, {}, - await readFile(await getAssetsDir('i18n', 'domains.ts')), + await getAssetsDir('i18n', 'domains.ts'), ); - const newContent = await readFile(joinPath(tmpDir, envDts)); + const newContent = await readFile(testContextFilePath); expect(() => checkTypes(newContent)).not.toThrow(); expect(newContent).toMatchInlineSnapshot(` - "/// - /// - /// - - // Enhance TypeScript's built-in typings. - import "@total-typescript/ts-reset"; - - import type { - Storefront, - CustomerAccount, - HydrogenCart, - HydrogenSessionData, - } from "@shopify/hydrogen"; - import type { - LanguageCode, - CountryCode, - } from "@shopify/hydrogen/storefront-api-types"; - import type { AppSession } from "~/lib/session"; - - declare global { - /** - * A global \`process\` object is only available during build to access NODE_ENV. - */ - const process: { env: { NODE_ENV: "production" | "development" } }; - - /** - * Declare expected Env parameter in fetch handler. - */ - interface Env { - SESSION_SECRET: string; - PUBLIC_STOREFRONT_API_TOKEN: string; - PRIVATE_STOREFRONT_API_TOKEN: string; - PUBLIC_STORE_DOMAIN: string; - PUBLIC_STOREFRONT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_URL: string; - PUBLIC_CHECKOUT_DOMAIN: string; - } - - /** - * The I18nLocale used for Storefront API query context. - */ - type I18nLocale = { language: LanguageCode; country: CountryCode }; - } + "import { createHydrogenContext } from "@shopify/hydrogen"; + import { AppSession } from "~/lib/session"; + import { CART_QUERY_FRAGMENT } from "~/lib/fragments"; + import { getLocaleFromRequest } from "~/lib/i18n"; - declare module "@shopify/remix-oxygen" { + /** + * The context implementation is separate from server.ts + * so that type can be extracted for AppLoadContext + * */ + export async function createAppLoadContext( + request: Request, + env: Env, + executionContext: ExecutionContext + ) { /** - * Declare local additions to the Remix loader context. + * Open a cache instance in the worker and a custom session instance. */ - interface AppLoadContext { - env: Env; - cart: HydrogenCart; - storefront: Storefront; - customerAccount: CustomerAccount; - session: AppSession; - waitUntil: ExecutionContext["waitUntil"]; + if (!env?.SESSION_SECRET) { + throw new Error("SESSION_SECRET environment variable is not set"); } - /** - * Declare local additions to the Remix session data. - */ - interface SessionData extends HydrogenSessionData {} + const waitUntil = executionContext.waitUntil.bind(executionContext); + const [cache, session] = await Promise.all([ + caches.open("hydrogen"), + AppSession.init(request, [env.SESSION_SECRET]), + ]); + + const hydrogenContext = createHydrogenContext({ + env, + request, + cache, + waitUntil, + session, + i18n: getLocaleFromRequest(request), + cart: { + queryFragment: CART_QUERY_FRAGMENT, + }, + }); + + return { + ...hydrogenContext, + // declare additional Remix loader context + }; } " `); }); }); - it('adds i18n type to server.ts', async () => { + it('adds i18n domains strategy to a file sitting next to the context file', async () => { await inTemporaryDirectory(async (tmpDir) => { - const skeletonDir = getSkeletonSourceDir(); - - await writeFile( - joinPath(tmpDir, serverTs), - // Remove the part that is not needed for this test (AppSession, Cart query, etc); - ( - await readFile(joinPath(skeletonDir, serverTs)) - ).replace(/^};$.*/ms, '};'), + const skeletonContextFilePath = joinPath( + getSkeletonSourceDir(), + contextTs, ); + const testContextFilePath = joinPath(tmpDir, contextTs); + + await copyFile(skeletonContextFilePath, testContextFilePath); - await replaceServerI18n( - {rootDirectory: tmpDir, serverEntryPoint: serverTs}, + await replaceContextI18n( + {rootDirectory: tmpDir, contextCreate: contextTs}, {}, - await readFile(await getAssetsDir('i18n', 'domains.ts')), - false, + await getAssetsDir('i18n', 'domains.ts'), ); - const newContent = await readFile(joinPath(tmpDir, serverTs)); + const newContent = await readFile(joinPath(tmpDir, expectedI18nFileTs)); expect(() => checkTypes(newContent)).not.toThrow(); expect(newContent).toMatchInlineSnapshot(` - "// @ts-ignore - // Virtual entry point for the app - import * as remixBuild from "virtual:remix/server-build"; - import { - cartGetIdDefault, - cartSetIdDefault, - createCartHandler, - createStorefrontClient, - storefrontRedirect, - createCustomerAccountClient, - } from "@shopify/hydrogen"; - import { - createRequestHandler, - getStorefrontHeaders, - type AppLoadContext, - } from "@shopify/remix-oxygen"; - import { AppSession } from "~/lib/session"; - import { CART_QUERY_FRAGMENT } from "~/lib/fragments"; + "import type { I18nBase } from "@shopify/hydrogen"; - /** - * Export a fetch handler in module format. - */ - export default { - async fetch( - request: Request, - env: Env, - executionContext: ExecutionContext - ): Promise { - try { - /** - * Open a cache instance in the worker and a custom session instance. - */ - if (!env?.SESSION_SECRET) { - throw new Error("SESSION_SECRET environment variable is not set"); - } - - const waitUntil = executionContext.waitUntil.bind(executionContext); - const [cache, session] = await Promise.all([ - caches.open("hydrogen"), - AppSession.init(request, [env.SESSION_SECRET]), - ]); - - /** - * Create Hydrogen's Storefront client. - */ - const { storefront } = createStorefrontClient({ - cache, - waitUntil, - i18n: getLocaleFromRequest(request), - publicStorefrontToken: env.PUBLIC_STOREFRONT_API_TOKEN, - privateStorefrontToken: env.PRIVATE_STOREFRONT_API_TOKEN, - storeDomain: env.PUBLIC_STORE_DOMAIN, - storefrontId: env.PUBLIC_STOREFRONT_ID, - storefrontHeaders: getStorefrontHeaders(request), - }); - - /** - * Create a client for Customer Account API. - */ - const customerAccount = createCustomerAccountClient({ - waitUntil, - request, - session, - customerAccountId: env.PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID, - customerAccountUrl: env.PUBLIC_CUSTOMER_ACCOUNT_API_URL, - }); - - /* - * Create a cart handler that will be used to - * create and update the cart in the session. - */ - const cart = createCartHandler({ - storefront, - customerAccount, - getCartId: cartGetIdDefault(request.headers), - setCartId: cartSetIdDefault(), - cartQueryFragment: CART_QUERY_FRAGMENT, - }); - - /** - * Create a Remix request handler and pass - * Hydrogen's Storefront client to the loader context. - */ - const handleRequest = createRequestHandler({ - build: remixBuild, - mode: process.env.NODE_ENV, - getLoadContext: (): AppLoadContext => ({ - session, - storefront, - customerAccount, - cart, - env, - waitUntil, - }), - }); - - const response = await handleRequest(request); - - if (session.isPending) { - response.headers.set("Set-Cookie", await session.commit()); - } - - if (response.status === 404) { - /** - * Check for redirects only when there's a 404 from the app. - * If the redirect doesn't exist, then \`storefrontRedirect\` - * will pass through the 404 response. - */ - return storefrontRedirect({ request, response, storefront }); - } - - return response; - } catch (error) { - // eslint-disable-next-line no-console - console.error(error); - return new Response("An unexpected error occurred", { status: 500 }); - } - }, - }; - - function getLocaleFromRequest(request: Request): I18nLocale { - const defaultLocale: I18nLocale = { language: "EN", country: "US" }; + export function getLocaleFromRequest(request: Request): I18nBase { + const defaultLocale: I18nBase = { language: "EN", country: "US" }; const supportedLocales = { ES: "ES", FR: "FR", DE: "DE", JP: "JA", - } as Record; + } as Record; const url = new URL(request.url); const domain = url.hostname @@ -281,4 +148,169 @@ describe('i18n replacers', () => { `); }); }); + + it('adds i18n subdomains strategy to a file sitting next to the context file', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const skeletonContextFilePath = joinPath( + getSkeletonSourceDir(), + contextTs, + ); + const testContextFilePath = joinPath(tmpDir, contextTs); + + await copyFile(skeletonContextFilePath, testContextFilePath); + + await replaceContextI18n( + {rootDirectory: tmpDir, contextCreate: contextTs}, + {}, + await getAssetsDir('i18n', 'subdomains.ts'), + ); + + const newContent = await readFile(joinPath(tmpDir, expectedI18nFileTs)); + expect(() => checkTypes(newContent)).not.toThrow(); + + expect(newContent).toMatchInlineSnapshot(` + "import type { I18nBase } from "@shopify/hydrogen"; + + export function getLocaleFromRequest(request: Request): I18nBase { + const defaultLocale: I18nBase = { language: "EN", country: "US" }; + const supportedLocales = { + ES: "ES", + FR: "FR", + DE: "DE", + JP: "JA", + } as Record; + + const url = new URL(request.url); + const firstSubdomain = url.hostname + .split(".")[0] + ?.toUpperCase() as keyof typeof supportedLocales; + + return supportedLocales[firstSubdomain] + ? { language: supportedLocales[firstSubdomain], country: firstSubdomain } + : defaultLocale; + } + " + `); + }); + }); + + it('adds i18n subfolders strategy to a file sitting next to the context file', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const skeletonContextFilePath = joinPath( + getSkeletonSourceDir(), + contextTs, + ); + const testContextFilePath = joinPath(tmpDir, contextTs); + + await copyFile(skeletonContextFilePath, testContextFilePath); + + await replaceContextI18n( + {rootDirectory: tmpDir, contextCreate: contextTs}, + {}, + await getAssetsDir('i18n', 'subfolders.ts'), + ); + + const newContent = await readFile(joinPath(tmpDir, expectedI18nFileTs)); + expect(() => checkTypes(newContent)).not.toThrow(); + + expect(newContent).toMatchInlineSnapshot(` + "import type { I18nBase } from "@shopify/hydrogen"; + + export interface I18nLocale extends I18nBase { + pathPrefix: string; + } + + export function getLocaleFromRequest(request: Request): I18nLocale { + const url = new URL(request.url); + const firstPathPart = url.pathname.split("/")[1]?.toUpperCase() ?? ""; + + type I18nFromUrl = [I18nLocale["language"], I18nLocale["country"]]; + + let pathPrefix = ""; + let [language, country]: I18nFromUrl = ["EN", "US"]; + + if (/^[A-Z]{2}-[A-Z]{2}$/i.test(firstPathPart)) { + pathPrefix = "/" + firstPathPart; + [language, country] = firstPathPart.split("-") as I18nFromUrl; + } + + return { language, country, pathPrefix }; + } + " + `); + }); + }); + + it('does not add i18n strategy file if it already exist', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const skeletonContextFilePath = joinPath( + getSkeletonSourceDir(), + contextTs, + ); + const testContextFilePath = joinPath(tmpDir, contextTs); + + await copyFile(skeletonContextFilePath, testContextFilePath); + + await copyFile( + await getAssetsDir('i18n', 'domains.ts'), + joinPath(tmpDir, dirname(contextTs), 'i18n.ts'), + ); + + expect(async () => { + await replaceContextI18n( + {rootDirectory: tmpDir, contextCreate: contextTs}, + {}, + await getAssetsDir('i18n', 'domains.ts'), + ); + }).rejects.toThrow(); + }); + }); + + it('adds js i18n domains strategy to a file sitting next to the context file', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const skeletonContextFilePath = joinPath( + getSkeletonSourceDir(), + contextTs, + ); + const testContextFilePath = joinPath(tmpDir, contextJs); + + await copyFile(skeletonContextFilePath, testContextFilePath); + + await replaceContextI18n( + {rootDirectory: tmpDir, contextCreate: contextJs}, + {}, + await getAssetsDir('i18n', 'domains.ts'), + ); + + expect(await fileExists(expectedI18nFileTs)).toBe(false); + + const newContent = await readFile(joinPath(tmpDir, expectedI18nFileJs)); + expect(() => checkTypes(newContent)).not.toThrow(); + + expect(newContent).toMatchInlineSnapshot(` + "/** + * @param {Request} request + */ + export function getLocaleFromRequest(request) { + const defaultLocale = { language: "EN", country: "US" }; + const supportedLocales = { + ES: "ES", + FR: "FR", + DE: "DE", + JP: "JA", + }; + + const url = new URL(request.url); + const domain = url.hostname.split(".").pop()?.toUpperCase(); + + return domain && supportedLocales[domain] + ? { language: supportedLocales[domain], country: domain } + : defaultLocale; + } + + /** @typedef {import('@shopify/hydrogen').I18nBase} I18nBase */ + " + `); + }); + }); }); diff --git a/packages/cli/src/lib/setups/i18n/replacers.ts b/packages/cli/src/lib/setups/i18n/replacers.ts index 4a48b12179..1147b151e1 100644 --- a/packages/cli/src/lib/setups/i18n/replacers.ts +++ b/packages/cli/src/lib/setups/i18n/replacers.ts @@ -1,6 +1,12 @@ import {AbortError} from '@shopify/cli-kit/node/error'; -import {joinPath, resolvePath} from '@shopify/cli-kit/node/path'; -import {fileExists} from '@shopify/cli-kit/node/fs'; +import { + joinPath, + resolvePath, + dirname, + relativePath, + extname, +} from '@shopify/cli-kit/node/path'; +import {fileExists, copyFile, readFile} from '@shopify/cli-kit/node/fs'; import {findFileWithExtension, replaceFileContent} from '../../file.js'; import type {FormatOptions} from '../../format-code.js'; import type {I18nSetupConfig} from './index.js'; @@ -8,25 +14,38 @@ import {importLangAstGrep} from '../../ast.js'; import {transpileFile} from '../../transpile/index.js'; /** - * Adds the `getLocaleFromRequest` function to the server entrypoint and calls it. + * Adds the `getLocaleFromRequest` function to createAppLoadContext method and calls it. */ -export async function replaceServerI18n( - {rootDirectory, serverEntryPoint = 'server'}: I18nSetupConfig, +export async function replaceContextI18n( + { + rootDirectory, + contextCreate = joinPath('app', 'lib', 'context.ts'), + }: I18nSetupConfig, formatConfig: FormatOptions, - localeExtractImpl: string, - isJs: boolean, + i18nStrategyFilePath: string, ) { - const {filepath, astType} = await findEntryFile({ + const createContextMethodName = 'createAppLoadContext'; + const {filepath, astType} = await findContextCreateFile({ rootDirectory, - serverEntryPoint, + contextCreate, }); + const localeExtractImpl = await readFile(i18nStrategyFilePath); + + const i18nFileFinalPath = await replaceI18nStrategy( + {rootDirectory, contextCreate}, + formatConfig, + i18nStrategyFilePath, + ); + await replaceFileContent(filepath, formatConfig, async (content) => { const astGrep = await importLangAstGrep(astType); const root = astGrep.parse(content).root(); - // First parameter of the `fetch` function. - // Normally it's called `request`, but it could be renamed. + // -- Find all the places that need replacement in the context create file + + // Build i18n function call using request name (1st parameter of the `createAppLoadContext` function) + // and i18n function name from i18nStrategyFilePath file content const requestIdentifier = root.find({ rule: { kind: 'identifier', @@ -34,15 +53,11 @@ export async function replaceServerI18n( kind: 'formal_parameters', stopBy: 'end', inside: { - kind: 'method_definition', + kind: 'function_declaration', stopBy: 'end', has: { - kind: 'property_identifier', - regex: '^fetch$', - }, - inside: { - kind: 'export_statement', - stopBy: 'end', + kind: 'identifier', + regex: `^${createContextMethodName}$`, }, }, }, @@ -61,7 +76,11 @@ export async function replaceServerI18n( const i18nFunctionCall = `${i18nFunctionName}(${requestIdentifierName})`; const hydrogenImportPath = '@shopify/hydrogen'; - const hydrogenImportName = 'createStorefrontClient'; + const hydrogenImportName = 'createHydrogenContext'; + + // -- Replace content in reversed order (bottom => top) to avoid changing string indexes + + // 1. Replace i18n option in createHydrogenContext() with i18n function call // Find the import statement for Hydrogen const importSpecifier = root.find({ @@ -90,7 +109,7 @@ export async function replaceServerI18n( if (!importName) { throw new AbortError( - `Could not find a Hydrogen import in ${serverEntryPoint}`, + `Could not find a Hydrogen import in ${contextCreate}`, `Please import "${hydrogenImportName}" from "${hydrogenImportPath}"`, ); } @@ -114,51 +133,11 @@ export async function replaceServerI18n( if (!argumentObject) { throw new AbortError( - `Could not find a Hydrogen client instantiation with an inline object as argument in ${serverEntryPoint}`, + `Could not find a Hydrogen client instantiation with an inline object as argument in ${contextCreate}`, `Please add a call to ${importName}({...})`, ); } - const defaultExportObject = root.find({ - rule: { - kind: 'export_statement', - regex: '^export default \\{', - }, - }); - - if (!defaultExportObject) { - throw new AbortError( - 'Could not find a default export in the server entry point', - ); - } - - let localeExtractFn = - localeExtractImpl.match(/^(\/\*\*.*?\*\/\n)?^function .+?^}/ms)?.[0] || - ''; - if (!localeExtractFn) { - throw new AbortError( - 'Could not find the locale extract function. This is a bug in Hydrogen.', - ); - } - - if (isJs) { - localeExtractFn = await transpileFile( - localeExtractFn, - 'locale-extract-server.ts', - ); - } else { - // Remove JSDoc comments for TS - localeExtractFn = localeExtractFn.replace(/\/\*\*.*?\*\//gms, ''); - } - - const defaultExportEnd = defaultExportObject.range().end.index; - - // Inject i18n function right after the default export - content = - content.slice(0, defaultExportEnd) + - `\n\n${localeExtractFn}\n` + - content.slice(defaultExportEnd); - const i18nProperty = argumentObject.find({ rule: { kind: 'property_identifier', @@ -192,142 +171,75 @@ export async function replaceServerI18n( content.slice(end.index - 1); } + // 2. Add i18n file import + const lastImport = root.findAll({rule: {kind: 'import_statement'}}).pop(); + const lastImportRange = lastImport?.range() ?? { + end: {index: 0}, + }; + + const i18nFunctionImport = joinPath( + '~', + relativePath( + joinPath(rootDirectory, 'app'), + i18nFileFinalPath.slice(0, -extname(i18nFileFinalPath).length), + ), + ); + + content = + content.slice(0, lastImportRange.end.index) + + `import {getLocaleFromRequest} from "${i18nFunctionImport}";` + + content.slice(lastImportRange.end.index); + return content; }); } /** - * Adds I18nLocale import and pass it to Storefront type as generic in `remix.env.d.ts` + * Adds I18nLocale file and update the i18n type import */ -export async function replaceRemixEnv( - {rootDirectory}: I18nSetupConfig, +async function replaceI18nStrategy( + { + rootDirectory, + contextCreate = joinPath('app', 'lib', 'context.ts'), + }: I18nSetupConfig, formatConfig: FormatOptions, - localeExtractImpl: string, + i18nStrategyFilePath: string, ) { - let envPath = joinPath(rootDirectory, 'env.d.ts'); + const isJs = contextCreate?.endsWith('.js') || false; - if (!(await fileExists(envPath))) { - // Try classic d.ts path - envPath = 'remix.env.d.ts'; - if (!(await fileExists(envPath))) { - return; // Skip silently - } - } - - // E.g. `type I18nLocale = {...};` - const i18nType = localeExtractImpl.match( - /^(export )?(type \w+ =\s+\{.*?\};)\n/ms, - )?.[2]; - // E.g. `I18nLocale` - const i18nTypeName = i18nType?.match(/^type (\w+)/)?.[1]; + const i18nPath = joinPath( + rootDirectory, + dirname(contextCreate), + isJs ? 'i18n.js' : 'i18n.ts', + ); - if (!i18nTypeName) { - // JavaScript project - return; // Skip silently + if (await fileExists(i18nPath)) { + throw new AbortError( + `${i18nPath} already exist. Renamed or remove the existing file before continue.`, + ); } - await replaceFileContent(envPath, formatConfig, async (content) => { - if (content.includes(`Storefront<`)) return; // Already set up - - const astGrep = await importLangAstGrep('ts'); - const root = astGrep.parse(content).root(); - - // -- Replace content in reversed order (bottom => top) to avoid changing string indexes + await copyFile(i18nStrategyFilePath, i18nPath); - // 1. Change `Storefront` to `Storefront` - const storefrontTypeNode = root.find({ - rule: { - kind: 'property_signature', - has: { - kind: 'type_annotation', - has: { - regex: '^Storefront$', - }, - }, - inside: { - kind: 'interface_declaration', - stopBy: 'end', - regex: 'AppLoadContext', - }, - }, - }); - - if (storefrontTypeNode) { - const storefrontTypeNodeRange = storefrontTypeNode.range(); - content = - content.slice(0, storefrontTypeNodeRange.end.index) + - `<${i18nTypeName}>` + - content.slice(storefrontTypeNodeRange.end.index); - } + await replaceFileContent(i18nPath, formatConfig, async (content) => { + content = content.replace(/\.\/mock-i18n-types\.js/, '@shopify/hydrogen'); - // 2. Build the global I18nLocale type - const ambientDeclarationContentNode = root.find({ - rule: { - kind: 'statement_block', - inside: { - kind: 'ambient_declaration', - }, - }, - }); - - const i18nTypeDeclaration = ` - /** - * The I18nLocale used for Storefront API query context. - */ - ${i18nType}`; - - if (ambientDeclarationContentNode) { - const {end} = ambientDeclarationContentNode.range(); - content = - content.slice(0, end.index - 1) + - `\n\n${i18nTypeDeclaration}\n` + - content.slice(end.index - 1); - } else { - content = content + `\n\ndeclare global {\n${i18nTypeDeclaration}\n}`; - } - - // 3. Import the required types - const importImplTypes = localeExtractImpl.match( - /import\s+type\s+[^;]+?;/, - )?.[0]; - - if (importImplTypes) { - const importPlace = - root - .findAll({ - rule: { - kind: 'import_statement', - has: { - kind: 'string_fragment', - stopBy: 'end', - regex: `^@shopify\/hydrogen$`, - }, - }, - }) - .pop() ?? - root.findAll({rule: {kind: 'import_statement'}}).pop() ?? - root.findAll({rule: {kind: 'comment', regex: '^/// { @@ -24,25 +22,4 @@ describe('Setup i18n with subdomains', () => { country: 'ES', }); }); - - it('does not access imported types directly', async () => { - const template = await readFile( - await getAssetsDir('i18n', 'subdomains.ts'), - ); - - const typeImports = (template.match(/import\s+type\s+{([^}]+)}/)?.[1] || '') - .trim() - .split(/\s*,\s*/); - - expect(typeImports).not.toHaveLength(0); - - // Assert that typed imports are not accessed directly but via `I18nLocale[...]` instead. - // These types are not imported in the final file. - const fnCode = template.match(/function .*\n}$/ms)?.[0] || ''; - expect(fnCode).toBeTruthy(); - - typeImports.forEach((typeImport) => - expect(fnCode).not.toContain(typeImport), - ); - }); }); diff --git a/packages/hydrogen/dev.env.d.ts b/packages/hydrogen/dev.env.d.ts index b24de505a2..8acc32f1d7 100644 --- a/packages/hydrogen/dev.env.d.ts +++ b/packages/hydrogen/dev.env.d.ts @@ -3,8 +3,8 @@ * Do not place here types needed for the library itself. */ -import type {HydrogenCart, Storefront} from './src/index'; -import type {WaitUntil} from './src/types'; +import type {HydrogenContext} from './src/index'; +import type {WaitUntil, HydrogenEnv} from './src/types'; declare global { /** @@ -12,16 +12,8 @@ declare global { */ const process: {env: {NODE_ENV: 'production' | 'development'}}; - /** - * Declare expected Env parameter in fetch handler. - */ - interface Env { - SESSION_SECRET: string; - PUBLIC_STOREFRONT_API_TOKEN: string; - PRIVATE_STOREFRONT_API_TOKEN: string; - PUBLIC_STORE_DOMAIN: string; - PUBLIC_STOREFRONT_ID: string; - PUBLIC_CHECKOUT_DOMAIN: string; + interface Env extends HydrogenEnv { + // declare additional Env parameter use in the fetch handler and Remix loader context here } /** @@ -37,14 +29,12 @@ declare global { type ExportedHandlerFetchHandler = Function; } -/** - * Declare local additions to `AppLoadContext` to include the session utilities we injected in `server.ts`. - * This is used in code for examples. - */ declare module '@shopify/remix-oxygen' { - export interface AppLoadContext { - storefront: Storefront; - env: Env; - cart: HydrogenCart; + interface AppLoadContext extends HydrogenContext { + // declare additional Remix loader context here + } + + interface SessionData extends HydrogenSessionData { + // declare local additions to the Remix session data here } } diff --git a/packages/hydrogen/docs/generated/generated_docs_data.json b/packages/hydrogen/docs/generated/generated_docs_data.json index 8feb8730dc..b879834f69 100644 --- a/packages/hydrogen/docs/generated/generated_docs_data.json +++ b/packages/hydrogen/docs/generated/generated_docs_data.json @@ -3675,7 +3675,7 @@ { "name": "requestHeaders", "description": "", - "value": "Headers", + "value": "Headers | { [key: string]: any; get?: (key: string) => string; }", "filePath": "/cart/cartGetIdDefault.ts" } ], @@ -3685,7 +3685,7 @@ "name": "", "value": "" }, - "value": "cartGetIdDefault = (requestHeaders: Headers) => {\n const cookies = parse(requestHeaders.get('Cookie') || '');\n return () => {\n return cookies.cart ? `gid://shopify/Cart/${cookies.cart}` : undefined;\n };\n}" + "value": "cartGetIdDefault = (\n requestHeaders: Headers | CrossRuntimeRequest['headers'],\n) => {\n const cookies = parse(\n (requestHeaders.get ? requestHeaders.get('Cookie') : undefined) || '',\n );\n return () => {\n return cookies.cart ? `gid://shopify/Cart/${cookies.cart}` : undefined;\n };\n}" }, "Headers": { "description": "", @@ -13030,6 +13030,799 @@ } ] }, + { + "name": "createHydrogenContext", + "category": "utilities", + "isVisualComponent": false, + "related": [ + { + "name": "createHydrogenContext", + "type": "utility", + "url": "/docs/api/hydrogen/2024-07/utilities/createhydrogencontext" + } + ], + "description": "\nThe `createHydrogenContext` function creates all the context object required to use Hydrogen utilities throughout a Hydrogen project.", + "type": "utility", + "defaultExample": { + "description": "I am the default example", + "codeblock": { + "tabs": [ + { + "title": "JavaScript", + "code": "import {createHydrogenContext} 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 waitUntil = executionContext.waitUntil.bind(executionContext);\n const [cache, session] = await Promise.all([\n caches.open('hydrogen'),\n AppSession.init(request, [env.SESSION_SECRET]),\n ]);\n\n /* Create context objects required to use Hydrogen with your credentials and options */\n const hydrogenContext = createHydrogenContext({\n /* Environment variables from the fetch function */\n env,\n /* Request object from the fetch function */\n request,\n /* Cache API instance */\n cache,\n /* Runtime utility in serverless environments */\n waitUntil,\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: () => ({...hydrogenContext}),\n });\n\n const response = await handleRequest(request);\n\n if (session.isPending) {\n response.headers.set('Set-Cookie', await session.commit());\n }\n\n return response;\n },\n};\n\nclass AppSession {\n isPending = false;\n\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.isPending = true;\n this.session.unset(key);\n }\n\n set(key, value) {\n this.isPending = true;\n this.session.set(key, value);\n }\n\n commit() {\n this.isPending = false;\n return this.sessionStorage.commitSession(this.session);\n }\n}\n", + "language": "jsx" + }, + { + "title": "TypeScript", + "code": "import {createHydrogenContext, type HydrogenSession} 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(request: Request, env: Env, executionContext: ExecutionContext) {\n const waitUntil = executionContext.waitUntil.bind(executionContext);\n const [cache, session] = await Promise.all([\n caches.open('hydrogen'),\n AppSession.init(request, [env.SESSION_SECRET]),\n ]);\n\n /* Create context objects required to use Hydrogen with your credentials and options */\n const hydrogenContext = createHydrogenContext({\n /* Environment variables from the fetch function */\n env,\n /* Request object from the fetch function */\n request,\n /* Cache API instance */\n cache,\n /* Runtime utility in serverless environments */\n waitUntil,\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: () => ({...hydrogenContext}),\n });\n\n const response = await handleRequest(request);\n\n if (session.isPending) {\n response.headers.set('Set-Cookie', await session.commit());\n }\n\n return response;\n },\n};\n\nclass AppSession implements HydrogenSession {\n public isPending = false;\n\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.isPending = true;\n this.session.unset(key);\n }\n\n set(key: string, value: any) {\n this.isPending = true;\n this.session.set(key, value);\n }\n\n commit() {\n this.isPending = false;\n return this.sessionStorage.commitSession(this.session);\n }\n}\n", + "language": "tsx" + } + ], + "title": "Example code" + } + }, + "definitions": [ + { + "title": "createHydrogenContext(options)", + "description": "", + "type": "HydrogenContextOptionsForDocs", + "typeDefinitions": { + "HydrogenContextOptionsForDocs": { + "filePath": "/createHydrogenContext.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "HydrogenContextOptionsForDocs", + "value": "{\n /* Environment variables from the fetch function */\n env: TEnv;\n /* Request object from the fetch function */\n request: Request | CrossRuntimeRequest;\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?: WaitUntil;\n /** Any cookie implementation. 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: TSession;\n /** An object containing a country code and language code */\n i18n?: TI18n;\n /** Whether it should print GraphQL errors automatically. Defaults to true */\n logErrors?: boolean | ((error?: Error) => boolean);\n /** Storefront client overwrite options. See documentation for createStorefrontClient for more information. */\n storefront?: {\n /** Storefront API headers. Default values set from request header. */\n headers?: StorefrontHeaders;\n /** Override the Storefront API version for this query. */\n apiVersion?: string;\n };\n /** Customer Account client overwrite options. See documentation for createCustomerAccountClient for more information. */\n customerAccount?: {\n /** Override the version of the API */\n apiVersion?: string;\n /** This is the route in your app that authorizes the customer after logging in. Make sure to call `customer.authorize()` within the loader on this route. It defaults to `/account/authorize`. */\n authUrl?: string;\n /** Use this method to overwrite the default logged-out redirect behavior. The default handler [throws a redirect](https://remix.run/docs/en/main/utils/redirect#:~:text=!session) to `/account/login` with current path as `return_to` query param. */\n customAuthStatusHandler?: () => Response | NonNullable | null;\n /** UNSTABLE feature, this will eventually goes away. If true then we will exchange customerAccessToken for storefrontCustomerAccessToken. */\n unstableB2b?: boolean;\n };\n /** Cart handler overwrite options. See documentation for createCartHandler for more information. */\n cart?: {\n /** A function that returns the cart id in the form of `gid://shopify/Cart/c1-123`. */\n getId?: () => string | undefined;\n /** A function that sets the cart ID. */\n setId?: (cartId: string) => Headers;\n /**\n * The cart query fragment used by `cart.get()`.\n * See the [example usage](/docs/api/hydrogen/2024-07/utilities/createcarthandler#example-cart-fragments) in the documentation.\n */\n queryFragment?: string;\n /**\n * The cart mutation fragment used in most mutation requests, except for `setMetafields` and `deleteMetafield`.\n * See the [example usage](/docs/api/hydrogen/2024-07/utilities/createcarthandler#example-cart-fragments) in the documentation.\n */\n mutateFragment?: string;\n /**\n * Define custom methods or override existing methods for your cart API instance.\n * See the [example usage](/docs/api/hydrogen/2024-07/utilities/createcarthandler#example-custom-methods) in the documentation.\n */\n customMethods?: Record;\n };\n}", + "description": "", + "members": [ + { + "filePath": "/createHydrogenContext.ts", + "syntaxKind": "PropertySignature", + "name": "env", + "value": "TEnv", + "description": "" + }, + { + "filePath": "/createHydrogenContext.ts", + "syntaxKind": "PropertySignature", + "name": "request", + "value": "Request | CrossRuntimeRequest", + "description": "" + }, + { + "filePath": "/createHydrogenContext.ts", + "syntaxKind": "PropertySignature", + "name": "cache", + "value": "Cache", + "description": "An instance that implements the [Cache API](https://developer.mozilla.org/en-US/docs/Web/API/Cache)", + "isOptional": true + }, + { + "filePath": "/createHydrogenContext.ts", + "syntaxKind": "PropertySignature", + "name": "waitUntil", + "value": "WaitUntil", + "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 + }, + { + "filePath": "/createHydrogenContext.ts", + "syntaxKind": "PropertySignature", + "name": "session", + "value": "TSession", + "description": "Any cookie implementation. 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": "/createHydrogenContext.ts", + "syntaxKind": "PropertySignature", + "name": "i18n", + "value": "TI18n", + "description": "An object containing a country code and language code", + "isOptional": true + }, + { + "filePath": "/createHydrogenContext.ts", + "syntaxKind": "PropertySignature", + "name": "logErrors", + "value": "boolean | ((error?: Error) => boolean)", + "description": "Whether it should print GraphQL errors automatically. Defaults to true", + "isOptional": true + }, + { + "filePath": "/createHydrogenContext.ts", + "syntaxKind": "PropertySignature", + "name": "storefront", + "value": "{ headers?: StorefrontHeaders; apiVersion?: string; }", + "description": "Storefront client overwrite options. See documentation for createStorefrontClient for more information.", + "isOptional": true + }, + { + "filePath": "/createHydrogenContext.ts", + "syntaxKind": "PropertySignature", + "name": "customerAccount", + "value": "{ apiVersion?: string; authUrl?: string; customAuthStatusHandler?: () => {} | Response; unstableB2b?: boolean; }", + "description": "Customer Account client overwrite options. See documentation for createCustomerAccountClient for more information.", + "isOptional": true + }, + { + "filePath": "/createHydrogenContext.ts", + "syntaxKind": "PropertySignature", + "name": "cart", + "value": "{ getId?: () => string; setId?: (cartId: string) => Headers; queryFragment?: string; mutateFragment?: string; customMethods?: Record; }", + "description": "Cart handler overwrite options. See documentation for createCartHandler for more information.", + "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": "" + } + ] + }, + "Headers": { + "description": "", + "name": "Headers", + "value": "Headers", + "members": [], + "override": "[Headers](https://developer.mozilla.org/en-US/docs/Web/API/Headers) - Web API" + } + } + }, + { + "title": "Returns", + "description": "", + "type": "HydrogenContext", + "typeDefinitions": { + "HydrogenContext": { + "filePath": "/createHydrogenContext.ts", + "name": "HydrogenContext", + "description": "", + "members": [ + { + "filePath": "/createHydrogenContext.ts", + "syntaxKind": "PropertySignature", + "name": "storefront", + "value": "Storefront", + "description": "A GraphQL client for querying the [Storefront API](https://shopify.dev/docs/api/storefront)." + }, + { + "filePath": "/createHydrogenContext.ts", + "syntaxKind": "PropertySignature", + "name": "customerAccount", + "value": "CustomerAccount", + "description": "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." + }, + { + "filePath": "/createHydrogenContext.ts", + "syntaxKind": "PropertySignature", + "name": "cart", + "value": "TCustomMethods extends CustomMethodsBase ? HydrogenCartCustom : HydrogenCart", + "description": "A collection of utilities used to interact with the cart." + }, + { + "filePath": "/createHydrogenContext.ts", + "syntaxKind": "PropertySignature", + "name": "env", + "value": "TEnv", + "description": "" + }, + { + "filePath": "/createHydrogenContext.ts", + "syntaxKind": "PropertySignature", + "name": "waitUntil", + "value": "WaitUntil", + "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 + }, + { + "filePath": "/createHydrogenContext.ts", + "syntaxKind": "PropertySignature", + "name": "session", + "value": "TSession", + "description": "Any cookie implementation. By default Hydrogen ships with cookie session storage, but you can use [another session storage](https://remix.run/docs/en/main/utils/sessions) implementation." + } + ], + "value": "export interface HydrogenContext<\n TSession extends HydrogenSession = HydrogenSession,\n TCustomMethods extends CustomMethodsBase | undefined = undefined,\n TI18n extends I18nBase = I18nBase,\n TEnv extends HydrogenEnv = Env,\n> {\n /** A GraphQL client for querying the [Storefront API](https://shopify.dev/docs/api/storefront). */\n storefront: StorefrontClient['storefront'];\n /** 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 customerAccount: CustomerAccount;\n /** A collection of utilities used to interact with the cart. */\n cart: TCustomMethods extends CustomMethodsBase\n ? HydrogenCartCustom\n : HydrogenCart;\n /* Request object from the fetch function */\n env: TEnv;\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?: WaitUntil;\n /** Any cookie implementation. 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: TSession;\n}" + }, + "Storefront": { + "filePath": "/storefront.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "Storefront", + "value": "{\n query: <\n OverrideReturnType extends any = never,\n RawGqlString extends string = string,\n >(\n query: RawGqlString,\n ...options: ClientVariablesInRestParams<\n StorefrontQueries,\n RawGqlString,\n StorefrontCommonExtraParams & Pick,\n AutoAddedVariableNames\n >\n ) => Promise<\n ClientReturn &\n StorefrontError\n >;\n mutate: <\n OverrideReturnType extends any = never,\n RawGqlString extends string = string,\n >(\n mutation: RawGqlString,\n ...options: ClientVariablesInRestParams<\n StorefrontMutations,\n RawGqlString,\n StorefrontCommonExtraParams,\n AutoAddedVariableNames\n >\n ) => Promise<\n ClientReturn &\n StorefrontError\n >;\n cache?: Cache;\n CacheNone: typeof CacheNone;\n CacheLong: typeof CacheLong;\n CacheShort: typeof CacheShort;\n CacheCustom: typeof CacheCustom;\n generateCacheControlHeader: typeof generateCacheControlHeader;\n getPublicTokenHeaders: ReturnType<\n typeof createStorefrontUtilities\n >['getPublicTokenHeaders'];\n getPrivateTokenHeaders: ReturnType<\n typeof createStorefrontUtilities\n >['getPrivateTokenHeaders'];\n getShopifyDomain: ReturnType<\n typeof createStorefrontUtilities\n >['getShopifyDomain'];\n getApiUrl: ReturnType<\n typeof createStorefrontUtilities\n >['getStorefrontApiUrl'];\n i18n: TI18n;\n}", + "description": "Interface to interact with the Storefront API.", + "members": [ + { + "filePath": "/storefront.ts", + "syntaxKind": "PropertySignature", + "name": "query", + "value": "(query: RawGqlString, ...options: IsOptionalVariables> extends true ? [(StorefrontCommonExtraParams & Pick & ClientVariables>]: StorefrontQueries[RawGqlString][\"variables\"][KeyType]; } & Partial>>)]: ({ [KeyType in keyof StorefrontQueries[RawGqlString][\"variables\"] as Filter>]: StorefrontQueries[RawGqlString][\"variables\"][KeyType]; } & Partial>>)[KeyType]; } : { readonly [variable: string]: unknown; }, Record<\"variables\", RawGqlString extends never ? { [KeyType in keyof ({ [KeyType in keyof StorefrontQueries[RawGqlString][\"variables\"] as Filter>]: StorefrontQueries[RawGqlString][\"variables\"][KeyType]; } & Partial>>)]: ({ [KeyType in keyof StorefrontQueries[RawGqlString][\"variables\"] as Filter>]: StorefrontQueries[RawGqlString][\"variables\"][KeyType]; } & Partial>>)[KeyType]; } : { readonly [variable: string]: unknown; }>>)?] : [StorefrontCommonExtraParams & Pick & ClientVariables>]: StorefrontQueries[RawGqlString][\"variables\"][KeyType]; } & Partial>>)]: ({ [KeyType in keyof StorefrontQueries[RawGqlString][\"variables\"] as Filter>]: StorefrontQueries[RawGqlString][\"variables\"][KeyType]; } & Partial>>)[KeyType]; } : { readonly [variable: string]: unknown; }, Record<\"variables\", RawGqlString extends never ? { [KeyType in keyof ({ [KeyType in keyof StorefrontQueries[RawGqlString][\"variables\"] as Filter>]: StorefrontQueries[RawGqlString][\"variables\"][KeyType]; } & Partial>>)]: ({ [KeyType in keyof StorefrontQueries[RawGqlString][\"variables\"] as Filter>]: StorefrontQueries[RawGqlString][\"variables\"][KeyType]; } & Partial>>)[KeyType]; } : { readonly [variable: string]: unknown; }>>]) => Promise & StorefrontError>", + "description": "" + }, + { + "filePath": "/storefront.ts", + "syntaxKind": "PropertySignature", + "name": "mutate", + "value": "(mutation: RawGqlString, ...options: IsOptionalVariables> extends true ? [(StorefrontCommonExtraParams & ClientVariables>]: StorefrontMutations[RawGqlString][\"variables\"][KeyType]; } & Partial>>)]: ({ [KeyType in keyof StorefrontMutations[RawGqlString][\"variables\"] as Filter>]: StorefrontMutations[RawGqlString][\"variables\"][KeyType]; } & Partial>>)[KeyType]; } : { readonly [variable: string]: unknown; }, Record<\"variables\", RawGqlString extends never ? { [KeyType in keyof ({ [KeyType in keyof StorefrontMutations[RawGqlString][\"variables\"] as Filter>]: StorefrontMutations[RawGqlString][\"variables\"][KeyType]; } & Partial>>)]: ({ [KeyType in keyof StorefrontMutations[RawGqlString][\"variables\"] as Filter>]: StorefrontMutations[RawGqlString][\"variables\"][KeyType]; } & Partial>>)[KeyType]; } : { readonly [variable: string]: unknown; }>>)?] : [StorefrontCommonExtraParams & ClientVariables>]: StorefrontMutations[RawGqlString][\"variables\"][KeyType]; } & Partial>>)]: ({ [KeyType in keyof StorefrontMutations[RawGqlString][\"variables\"] as Filter>]: StorefrontMutations[RawGqlString][\"variables\"][KeyType]; } & Partial>>)[KeyType]; } : { readonly [variable: string]: unknown; }, Record<\"variables\", RawGqlString extends never ? { [KeyType in keyof ({ [KeyType in keyof StorefrontMutations[RawGqlString][\"variables\"] as Filter>]: StorefrontMutations[RawGqlString][\"variables\"][KeyType]; } & Partial>>)]: ({ [KeyType in keyof StorefrontMutations[RawGqlString][\"variables\"] as Filter>]: StorefrontMutations[RawGqlString][\"variables\"][KeyType]; } & Partial>>)[KeyType]; } : { readonly [variable: string]: unknown; }>>]) => Promise & StorefrontError>", + "description": "" + }, + { + "filePath": "/storefront.ts", + "syntaxKind": "PropertySignature", + "name": "cache", + "value": "Cache", + "description": "", + "isOptional": true + }, + { + "filePath": "/storefront.ts", + "syntaxKind": "PropertySignature", + "name": "CacheNone", + "value": "() => NoStoreStrategy", + "description": "" + }, + { + "filePath": "/storefront.ts", + "syntaxKind": "PropertySignature", + "name": "CacheLong", + "value": "(overrideOptions?: AllCacheOptions) => AllCacheOptions", + "description": "" + }, + { + "filePath": "/storefront.ts", + "syntaxKind": "PropertySignature", + "name": "CacheShort", + "value": "(overrideOptions?: AllCacheOptions) => AllCacheOptions", + "description": "" + }, + { + "filePath": "/storefront.ts", + "syntaxKind": "PropertySignature", + "name": "CacheCustom", + "value": "(overrideOptions: AllCacheOptions) => AllCacheOptions", + "description": "" + }, + { + "filePath": "/storefront.ts", + "syntaxKind": "PropertySignature", + "name": "generateCacheControlHeader", + "value": "(cacheOptions: AllCacheOptions) => string", + "description": "" + }, + { + "filePath": "/storefront.ts", + "syntaxKind": "PropertySignature", + "name": "getPublicTokenHeaders", + "value": "(props?: Partial> & Pick) => Record", + "description": "" + }, + { + "filePath": "/storefront.ts", + "syntaxKind": "PropertySignature", + "name": "getPrivateTokenHeaders", + "value": "(props?: Partial> & Pick & { buyerIp?: string; }) => Record", + "description": "" + }, + { + "filePath": "/storefront.ts", + "syntaxKind": "PropertySignature", + "name": "getShopifyDomain", + "value": "(props?: Partial>) => string", + "description": "" + }, + { + "filePath": "/storefront.ts", + "syntaxKind": "PropertySignature", + "name": "getApiUrl", + "value": "(props?: Partial>) => string", + "description": "" + }, + { + "filePath": "/storefront.ts", + "syntaxKind": "PropertySignature", + "name": "i18n", + "value": "TI18n", + "description": "" + } + ] + }, + "StorefrontQueries": { + "filePath": "/storefront.ts", + "name": "StorefrontQueries", + "description": "Maps all the queries found in the project to variables and return types.", + "members": [], + "value": "export interface StorefrontQueries {\n // Example of how a generated query type looks like:\n // '#graphql query q1 {...}': {return: Q1Query; variables: Q1QueryVariables};\n}" + }, + "AutoAddedVariableNames": { + "filePath": "/storefront.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "AutoAddedVariableNames", + "value": "'country' | 'language'", + "description": "" + }, + "StorefrontCommonExtraParams": { + "filePath": "/storefront.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "StorefrontCommonExtraParams", + "value": "{\n headers?: HeadersInit;\n storefrontApiVersion?: string;\n displayName?: string;\n}", + "description": "", + "members": [ + { + "filePath": "/storefront.ts", + "syntaxKind": "PropertySignature", + "name": "headers", + "value": "HeadersInit", + "description": "", + "isOptional": true + }, + { + "filePath": "/storefront.ts", + "syntaxKind": "PropertySignature", + "name": "storefrontApiVersion", + "value": "string", + "description": "", + "isOptional": true + }, + { + "filePath": "/storefront.ts", + "syntaxKind": "PropertySignature", + "name": "displayName", + "value": "string", + "description": "", + "isOptional": true + } + ] + }, + "StorefrontQueryOptions": { + "filePath": "/storefront.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "StorefrontQueryOptions", + "value": "StorefrontCommonExtraParams & {\n query: string;\n mutation?: never;\n cache?: CachingStrategy;\n}", + "description": "" + }, + "CachingStrategy": { + "filePath": "/cache/strategies.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "CachingStrategy", + "value": "AllCacheOptions", + "description": "Use the `CachingStrategy` to define a custom caching mechanism for your data. Or use one of the pre-defined caching strategies: CacheNone, CacheShort, CacheLong.", + "members": [ + { + "filePath": "/cache/strategies.ts", + "syntaxKind": "PropertySignature", + "name": "mode", + "value": "string", + "description": "The caching mode, generally `public`, `private`, or `no-store`.", + "isOptional": true + }, + { + "filePath": "/cache/strategies.ts", + "syntaxKind": "PropertySignature", + "name": "maxAge", + "value": "number", + "description": "The maximum amount of time in seconds that a resource will be considered fresh. See `max-age` in the [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#:~:text=Response%20Directives-,max%2Dage,-The%20max%2Dage).", + "isOptional": true + }, + { + "filePath": "/cache/strategies.ts", + "syntaxKind": "PropertySignature", + "name": "staleWhileRevalidate", + "value": "number", + "description": "Indicate that the cache should serve the stale response in the background while revalidating the cache. See `stale-while-revalidate` in the [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#stale-while-revalidate).", + "isOptional": true + }, + { + "filePath": "/cache/strategies.ts", + "syntaxKind": "PropertySignature", + "name": "sMaxAge", + "value": "number", + "description": "Similar to `maxAge` but specific to shared caches. See `s-maxage` in the [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#s-maxage).", + "isOptional": true + }, + { + "filePath": "/cache/strategies.ts", + "syntaxKind": "PropertySignature", + "name": "staleIfError", + "value": "number", + "description": "Indicate that the cache should serve the stale response if an error occurs while revalidating the cache. See `stale-if-error` in the [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#stale-if-error).", + "isOptional": true + } + ] + }, + "StorefrontError": { + "filePath": "/storefront.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "StorefrontError", + "value": "{\n errors?: StorefrontApiErrors;\n}", + "description": "", + "members": [ + { + "filePath": "/storefront.ts", + "syntaxKind": "PropertySignature", + "name": "errors", + "value": "StorefrontApiErrors", + "description": "", + "isOptional": true + } + ] + }, + "StorefrontApiErrors": { + "filePath": "/storefront.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "StorefrontApiErrors", + "value": "JsonGraphQLError[] | undefined", + "description": "" + }, + "StorefrontMutations": { + "filePath": "/storefront.ts", + "name": "StorefrontMutations", + "description": "Maps all the mutations found in the project to variables and return types.", + "members": [], + "value": "export interface StorefrontMutations {\n // Example of how a generated mutation type looks like:\n // '#graphql mutation m1 {...}': {return: M1Mutation; variables: M1MutationVariables};\n}" + }, + "NoStoreStrategy": { + "filePath": "/cache/strategies.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "NoStoreStrategy", + "value": "{\n mode: string;\n}", + "description": "", + "members": [ + { + "filePath": "/cache/strategies.ts", + "syntaxKind": "PropertySignature", + "name": "mode", + "value": "string", + "description": "" + } + ] + }, + "AllCacheOptions": { + "filePath": "/cache/strategies.ts", + "name": "AllCacheOptions", + "description": "Override options for a cache strategy.", + "members": [ + { + "filePath": "/cache/strategies.ts", + "syntaxKind": "PropertySignature", + "name": "mode", + "value": "string", + "description": "The caching mode, generally `public`, `private`, or `no-store`.", + "isOptional": true + }, + { + "filePath": "/cache/strategies.ts", + "syntaxKind": "PropertySignature", + "name": "maxAge", + "value": "number", + "description": "The maximum amount of time in seconds that a resource will be considered fresh. See `max-age` in the [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#:~:text=Response%20Directives-,max%2Dage,-The%20max%2Dage).", + "isOptional": true + }, + { + "filePath": "/cache/strategies.ts", + "syntaxKind": "PropertySignature", + "name": "staleWhileRevalidate", + "value": "number", + "description": "Indicate that the cache should serve the stale response in the background while revalidating the cache. See `stale-while-revalidate` in the [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#stale-while-revalidate).", + "isOptional": true + }, + { + "filePath": "/cache/strategies.ts", + "syntaxKind": "PropertySignature", + "name": "sMaxAge", + "value": "number", + "description": "Similar to `maxAge` but specific to shared caches. See `s-maxage` in the [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#s-maxage).", + "isOptional": true + }, + { + "filePath": "/cache/strategies.ts", + "syntaxKind": "PropertySignature", + "name": "staleIfError", + "value": "number", + "description": "Indicate that the cache should serve the stale response if an error occurs while revalidating the cache. See `stale-if-error` in the [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#stale-if-error).", + "isOptional": true + } + ], + "value": "export interface AllCacheOptions {\n /**\n * The caching mode, generally `public`, `private`, or `no-store`.\n */\n mode?: string;\n /**\n * The maximum amount of time in seconds that a resource will be considered fresh. See `max-age` in the [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#:~:text=Response%20Directives-,max%2Dage,-The%20max%2Dage).\n */\n maxAge?: number;\n /**\n * Indicate that the cache should serve the stale response in the background while revalidating the cache. See `stale-while-revalidate` in the [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#stale-while-revalidate).\n */\n staleWhileRevalidate?: number;\n /**\n * Similar to `maxAge` but specific to shared caches. See `s-maxage` in the [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#s-maxage).\n */\n sMaxAge?: number;\n /**\n * Indicate that the cache should serve the stale response if an error occurs while revalidating the cache. See `stale-if-error` in the [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#stale-if-error).\n */\n staleIfError?: number;\n}" + }, + "CustomerAccount": { + "filePath": "/customer/types.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "CustomerAccount", + "value": "{\n /** Start the OAuth login flow. This function should be called and returned from a Remix action.\n * It redirects the customer to a Shopify login domain. It also defined the final path the customer\n * lands on at the end of the oAuth flow with the value of the `return_to` query param. (This is\n * automatically setup unless `customAuthStatusHandler` option is in use)\n *\n * @param options.uiLocales - The displayed language of the login page. Only support for the following languages:\n * `en`, `fr`, `cs`, `da`, `de`, `es`, `fi`, `it`, `ja`, `ko`, `nb`, `nl`, `pl`, `pt-BR`, `pt-PT`,\n * `sv`, `th`, `tr`, `vi`, `zh-CN`, `zh-TW`. If supplied any other language code, it will default to `en`.\n * */\n login: (options?: LoginOptions) => Promise;\n /** On successful login, the customer redirects 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 in admin. */\n authorize: () => Promise;\n /** Returns if the customer is logged in. It also checks if the access token is expired and refreshes it if needed. */\n isLoggedIn: () => Promise;\n /** Check for a not logged in customer and redirect customer to login page. The redirect can be overwritten with `customAuthStatusHandler` option. */\n handleAuthStatus: () => void | DataFunctionValue;\n /** Returns CustomerAccessToken if the customer is logged in. It also run a expiry check and does a token refresh if needed. */\n getAccessToken: () => Promise;\n /** Creates the fully-qualified URL to your store's GraphQL endpoint.*/\n getApiUrl: () => string;\n /** Logout the customer by clearing the session and redirecting to the login domain. It should be called and returned from a Remix action. The path app should redirect to after logout can be setup in Customer Account API settings in admin.\n *\n * @param options.postLogoutRedirectUri - The url to redirect customer to after logout, should be a relative URL. This url will need to included in Customer Account API's application setup for logout URI. The default value is current app origin, which is automatically setup in admin when using `--customer-account-push` flag with dev.\n * */\n logout: (options?: LogoutOptions) => Promise;\n /** Execute a GraphQL query against the Customer Account API. This method execute `handleAuthStatus()` ahead of query. */\n query: <\n OverrideReturnType extends any = never,\n RawGqlString extends string = string,\n >(\n query: RawGqlString,\n ...options: ClientVariablesInRestParams<\n CustomerAccountQueries,\n RawGqlString\n >\n ) => Promise<\n Omit<\n CustomerAPIResponse<\n ClientReturn\n >,\n 'errors'\n > & {errors?: JsonGraphQLError[]}\n >;\n /** Execute a GraphQL mutation against the Customer Account API. This method execute `handleAuthStatus()` ahead of mutation. */\n mutate: <\n OverrideReturnType extends any = never,\n RawGqlString extends string = string,\n >(\n mutation: RawGqlString,\n ...options: ClientVariablesInRestParams<\n CustomerAccountMutations,\n RawGqlString\n >\n ) => Promise<\n Omit<\n CustomerAPIResponse<\n ClientReturn\n >,\n 'errors'\n > & {errors?: JsonGraphQLError[]}\n >;\n /** UNSTABLE feature. Set buyer information into session.*/\n UNSTABLE_setBuyer: (buyer: Buyer) => void;\n /** UNSTABLE feature. Get buyer token and company location id from session.*/\n UNSTABLE_getBuyer: () => Promise;\n}", + "description": "", + "members": [ + { + "filePath": "/customer/types.ts", + "syntaxKind": "PropertySignature", + "name": "login", + "value": "(options?: LoginOptions) => Promise", + "description": "Start the OAuth login flow. This function should be called and returned from a Remix action. It redirects the customer to a Shopify login domain. It also defined the final path the customer lands on at the end of the oAuth flow with the value of the `return_to` query param. (This is automatically setup unless `customAuthStatusHandler` option is in use)" + }, + { + "filePath": "/customer/types.ts", + "syntaxKind": "PropertySignature", + "name": "authorize", + "value": "() => Promise", + "description": "On successful login, the customer redirects 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 in admin." + }, + { + "filePath": "/customer/types.ts", + "syntaxKind": "PropertySignature", + "name": "isLoggedIn", + "value": "() => Promise", + "description": "Returns if the customer is logged in. It also checks if the access token is expired and refreshes it if needed." + }, + { + "filePath": "/customer/types.ts", + "syntaxKind": "PropertySignature", + "name": "handleAuthStatus", + "value": "() => void | DataFunctionValue", + "description": "Check for a not logged in customer and redirect customer to login page. The redirect can be overwritten with `customAuthStatusHandler` option." + }, + { + "filePath": "/customer/types.ts", + "syntaxKind": "PropertySignature", + "name": "getAccessToken", + "value": "() => Promise", + "description": "Returns CustomerAccessToken if the customer is logged in. It also run a expiry check and does a token refresh if needed." + }, + { + "filePath": "/customer/types.ts", + "syntaxKind": "PropertySignature", + "name": "getApiUrl", + "value": "() => string", + "description": "Creates the fully-qualified URL to your store's GraphQL endpoint." + }, + { + "filePath": "/customer/types.ts", + "syntaxKind": "PropertySignature", + "name": "logout", + "value": "(options?: LogoutOptions) => Promise", + "description": "Logout the customer by clearing the session and redirecting to the login domain. It should be called and returned from a Remix action. The path app should redirect to after logout can be setup in Customer Account API settings in admin." + }, + { + "filePath": "/customer/types.ts", + "syntaxKind": "PropertySignature", + "name": "query", + "value": "(query: RawGqlString, ...options: IsOptionalVariables> extends true ? [({} & ClientVariables>]: CustomerAccountQueries[RawGqlString][\"variables\"][KeyType]; } & Partial>>)]: ({ [KeyType in keyof CustomerAccountQueries[RawGqlString][\"variables\"] as Filter>]: CustomerAccountQueries[RawGqlString][\"variables\"][KeyType]; } & Partial>>)[KeyType]; } : { readonly [variable: string]: unknown; }, Record<\"variables\", RawGqlString extends never ? { [KeyType in keyof ({ [KeyType in keyof CustomerAccountQueries[RawGqlString][\"variables\"] as Filter>]: CustomerAccountQueries[RawGqlString][\"variables\"][KeyType]; } & Partial>>)]: ({ [KeyType in keyof CustomerAccountQueries[RawGqlString][\"variables\"] as Filter>]: CustomerAccountQueries[RawGqlString][\"variables\"][KeyType]; } & Partial>>)[KeyType]; } : { readonly [variable: string]: unknown; }>>)?] : [{} & ClientVariables>]: CustomerAccountQueries[RawGqlString][\"variables\"][KeyType]; } & Partial>>)]: ({ [KeyType in keyof CustomerAccountQueries[RawGqlString][\"variables\"] as Filter>]: CustomerAccountQueries[RawGqlString][\"variables\"][KeyType]; } & Partial>>)[KeyType]; } : { readonly [variable: string]: unknown; }, Record<\"variables\", RawGqlString extends never ? { [KeyType in keyof ({ [KeyType in keyof CustomerAccountQueries[RawGqlString][\"variables\"] as Filter>]: CustomerAccountQueries[RawGqlString][\"variables\"][KeyType]; } & Partial>>)]: ({ [KeyType in keyof CustomerAccountQueries[RawGqlString][\"variables\"] as Filter>]: CustomerAccountQueries[RawGqlString][\"variables\"][KeyType]; } & Partial>>)[KeyType]; } : { readonly [variable: string]: unknown; }>>]) => Promise>, \"errors\"> & { errors?: Pick[]; }>", + "description": "Execute a GraphQL query against the Customer Account API. This method execute `handleAuthStatus()` ahead of query." + }, + { + "filePath": "/customer/types.ts", + "syntaxKind": "PropertySignature", + "name": "mutate", + "value": "(mutation: RawGqlString, ...options: IsOptionalVariables> extends true ? [({} & ClientVariables>]: CustomerAccountMutations[RawGqlString][\"variables\"][KeyType]; } & Partial>>)]: ({ [KeyType in keyof CustomerAccountMutations[RawGqlString][\"variables\"] as Filter>]: CustomerAccountMutations[RawGqlString][\"variables\"][KeyType]; } & Partial>>)[KeyType]; } : { readonly [variable: string]: unknown; }, Record<\"variables\", RawGqlString extends never ? { [KeyType in keyof ({ [KeyType in keyof CustomerAccountMutations[RawGqlString][\"variables\"] as Filter>]: CustomerAccountMutations[RawGqlString][\"variables\"][KeyType]; } & Partial>>)]: ({ [KeyType in keyof CustomerAccountMutations[RawGqlString][\"variables\"] as Filter>]: CustomerAccountMutations[RawGqlString][\"variables\"][KeyType]; } & Partial>>)[KeyType]; } : { readonly [variable: string]: unknown; }>>)?] : [{} & ClientVariables>]: CustomerAccountMutations[RawGqlString][\"variables\"][KeyType]; } & Partial>>)]: ({ [KeyType in keyof CustomerAccountMutations[RawGqlString][\"variables\"] as Filter>]: CustomerAccountMutations[RawGqlString][\"variables\"][KeyType]; } & Partial>>)[KeyType]; } : { readonly [variable: string]: unknown; }, Record<\"variables\", RawGqlString extends never ? { [KeyType in keyof ({ [KeyType in keyof CustomerAccountMutations[RawGqlString][\"variables\"] as Filter>]: CustomerAccountMutations[RawGqlString][\"variables\"][KeyType]; } & Partial>>)]: ({ [KeyType in keyof CustomerAccountMutations[RawGqlString][\"variables\"] as Filter>]: CustomerAccountMutations[RawGqlString][\"variables\"][KeyType]; } & Partial>>)[KeyType]; } : { readonly [variable: string]: unknown; }>>]) => Promise>, \"errors\"> & { errors?: Pick[]; }>", + "description": "Execute a GraphQL mutation against the Customer Account API. This method execute `handleAuthStatus()` ahead of mutation." + }, + { + "filePath": "/customer/types.ts", + "syntaxKind": "PropertySignature", + "name": "UNSTABLE_setBuyer", + "value": "(buyer: Partial) => void", + "description": "UNSTABLE feature. Set buyer information into session." + }, + { + "filePath": "/customer/types.ts", + "syntaxKind": "PropertySignature", + "name": "UNSTABLE_getBuyer", + "value": "() => Promise>", + "description": "UNSTABLE feature. Get buyer token and company location id from session." + } + ] + }, + "LoginOptions": { + "filePath": "/customer/types.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "LoginOptions", + "value": "{\n uiLocales?: LanguageCode;\n}", + "description": "", + "members": [ + { + "filePath": "/customer/types.ts", + "syntaxKind": "PropertySignature", + "name": "uiLocales", + "value": "LanguageCode", + "description": "", + "isOptional": true + } + ] + }, + "DataFunctionValue": { + "filePath": "/customer/types.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "DataFunctionValue", + "value": "Response | NonNullable | null", + "description": "" + }, + "LogoutOptions": { + "filePath": "/customer/types.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "LogoutOptions", + "value": "{\n postLogoutRedirectUri?: string;\n}", + "description": "", + "members": [ + { + "filePath": "/customer/types.ts", + "syntaxKind": "PropertySignature", + "name": "postLogoutRedirectUri", + "value": "string", + "description": "", + "isOptional": true + } + ] + }, + "CustomerAccountQueries": { + "filePath": "/customer/types.ts", + "name": "CustomerAccountQueries", + "description": "", + "members": [], + "value": "export interface CustomerAccountQueries {\n // Example of how a generated query type looks like:\n // '#graphql query q1 {...}': {return: Q1Query; variables: Q1QueryVariables};\n}" + }, + "CustomerAccountMutations": { + "filePath": "/customer/types.ts", + "name": "CustomerAccountMutations", + "description": "", + "members": [], + "value": "export interface CustomerAccountMutations {\n // Example of how a generated mutation type looks like:\n // '#graphql mutation m1 {...}': {return: M1Mutation; variables: M1MutationVariables};\n}" + }, + "CustomMethodsBase": { + "filePath": "/cart/createCartHandler.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "CustomMethodsBase", + "value": "Record", + "description": "", + "members": [] + }, + "HydrogenCartCustom": { + "filePath": "/cart/createCartHandler.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "HydrogenCartCustom", + "value": "Omit & TCustomMethods", + "description": "" + }, + "HydrogenCart": { + "filePath": "/cart/createCartHandler.ts", + "syntaxKind": "TypeAliasDeclaration", + "name": "HydrogenCart", + "value": "{\n get: ReturnType;\n getCartId: () => string | undefined;\n setCartId: (cartId: string) => Headers;\n create: ReturnType;\n addLines: ReturnType;\n updateLines: ReturnType;\n removeLines: ReturnType;\n updateDiscountCodes: ReturnType;\n updateBuyerIdentity: ReturnType;\n updateNote: ReturnType;\n updateSelectedDeliveryOption: ReturnType<\n typeof cartSelectedDeliveryOptionsUpdateDefault\n >;\n updateAttributes: ReturnType;\n setMetafields: ReturnType;\n deleteMetafield: ReturnType;\n}", + "description": "", + "members": [ + { + "filePath": "/cart/createCartHandler.ts", + "syntaxKind": "PropertySignature", + "name": "get", + "value": "ReturnType", + "description": "" + }, + { + "filePath": "/cart/createCartHandler.ts", + "syntaxKind": "PropertySignature", + "name": "getCartId", + "value": "() => string", + "description": "" + }, + { + "filePath": "/cart/createCartHandler.ts", + "syntaxKind": "PropertySignature", + "name": "setCartId", + "value": "(cartId: string) => Headers", + "description": "" + }, + { + "filePath": "/cart/createCartHandler.ts", + "syntaxKind": "PropertySignature", + "name": "create", + "value": "ReturnType", + "description": "" + }, + { + "filePath": "/cart/createCartHandler.ts", + "syntaxKind": "PropertySignature", + "name": "addLines", + "value": "ReturnType", + "description": "" + }, + { + "filePath": "/cart/createCartHandler.ts", + "syntaxKind": "PropertySignature", + "name": "updateLines", + "value": "ReturnType", + "description": "" + }, + { + "filePath": "/cart/createCartHandler.ts", + "syntaxKind": "PropertySignature", + "name": "removeLines", + "value": "ReturnType", + "description": "" + }, + { + "filePath": "/cart/createCartHandler.ts", + "syntaxKind": "PropertySignature", + "name": "updateDiscountCodes", + "value": "ReturnType", + "description": "" + }, + { + "filePath": "/cart/createCartHandler.ts", + "syntaxKind": "PropertySignature", + "name": "updateBuyerIdentity", + "value": "ReturnType", + "description": "" + }, + { + "filePath": "/cart/createCartHandler.ts", + "syntaxKind": "PropertySignature", + "name": "updateNote", + "value": "ReturnType", + "description": "" + }, + { + "filePath": "/cart/createCartHandler.ts", + "syntaxKind": "PropertySignature", + "name": "updateSelectedDeliveryOption", + "value": "ReturnType<\n typeof cartSelectedDeliveryOptionsUpdateDefault\n >", + "description": "" + }, + { + "filePath": "/cart/createCartHandler.ts", + "syntaxKind": "PropertySignature", + "name": "updateAttributes", + "value": "ReturnType", + "description": "" + }, + { + "filePath": "/cart/createCartHandler.ts", + "syntaxKind": "PropertySignature", + "name": "setMetafields", + "value": "ReturnType", + "description": "" + }, + { + "filePath": "/cart/createCartHandler.ts", + "syntaxKind": "PropertySignature", + "name": "deleteMetafield", + "value": "ReturnType", + "description": "" + } + ] + }, + "Headers": { + "description": "", + "name": "Headers", + "value": "Headers", + "members": [], + "override": "[Headers](https://developer.mozilla.org/en-US/docs/Web/API/Headers) - Web API" + } + } + } + ] + }, { "name": "createStorefrontClient", "category": "utilities", @@ -13150,43 +13943,6 @@ "isOptional": true } ] - }, - "StorefrontHeaders": { - "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 /** The purpose header value for debugging */\n purpose: string | null;\n}", - "description": "", - "members": [ - { - "filePath": "/storefront.ts", - "syntaxKind": "PropertySignature", - "name": "requestGroupId", - "value": "string", - "description": "A unique ID that correlates all sub-requests together." - }, - { - "filePath": "/storefront.ts", - "syntaxKind": "PropertySignature", - "name": "buyerIp", - "value": "string", - "description": "The IP address of the client." - }, - { - "filePath": "/storefront.ts", - "syntaxKind": "PropertySignature", - "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" - } - ] } } }, diff --git a/packages/hydrogen/src/cart/cartGetIdDefault.ts b/packages/hydrogen/src/cart/cartGetIdDefault.ts index 190bee2a5f..412a236d9b 100644 --- a/packages/hydrogen/src/cart/cartGetIdDefault.ts +++ b/packages/hydrogen/src/cart/cartGetIdDefault.ts @@ -1,7 +1,10 @@ import {parse} from 'worktop/cookie'; +import {type CrossRuntimeRequest, getHeaderValue} from '../utils/request'; -export const cartGetIdDefault = (requestHeaders: Headers) => { - const cookies = parse(requestHeaders.get('Cookie') || ''); +export const cartGetIdDefault = ( + requestHeaders: CrossRuntimeRequest['headers'], +) => { + const cookies = parse(getHeaderValue(requestHeaders, 'Cookie') || ''); return () => { return cookies.cart ? `gid://shopify/Cart/${cookies.cart}` : undefined; }; diff --git a/packages/hydrogen/src/createHydrogenContext.doc.ts b/packages/hydrogen/src/createHydrogenContext.doc.ts new file mode 100644 index 0000000000..f6f22699b7 --- /dev/null +++ b/packages/hydrogen/src/createHydrogenContext.doc.ts @@ -0,0 +1,49 @@ +import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs'; + +const data: ReferenceEntityTemplateSchema = { + name: 'createHydrogenContext', + category: 'utilities', + isVisualComponent: false, + related: [ + { + name: 'createHydrogenContext', + type: 'utility', + url: '/docs/api/hydrogen/2024-07/utilities/createhydrogencontext', + }, + ], + description: ` +The \`createHydrogenContext\` function creates the context object required to use Hydrogen utilities throughout a Hydrogen project.`, + type: 'utility', + defaultExample: { + description: 'I am the default example', + codeblock: { + tabs: [ + { + title: 'JavaScript', + code: './createHydrogenContext.example.jsx', + language: 'jsx', + }, + { + title: 'TypeScript', + code: './createHydrogenContext.example.tsx', + language: 'tsx', + }, + ], + title: 'Example code', + }, + }, + definitions: [ + { + title: 'createHydrogenContext(options)', + type: 'HydrogenContextOptionsForDocs', + description: '', + }, + { + title: 'Returns', + type: 'HydrogenContext', + description: '', + }, + ], +}; + +export default data; diff --git a/packages/hydrogen/src/createHydrogenContext.example.jsx b/packages/hydrogen/src/createHydrogenContext.example.jsx new file mode 100644 index 0000000000..a06174376e --- /dev/null +++ b/packages/hydrogen/src/createHydrogenContext.example.jsx @@ -0,0 +1,91 @@ +import {createHydrogenContext} 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 waitUntil = executionContext.waitUntil.bind(executionContext); + const [cache, session] = await Promise.all([ + caches.open('hydrogen'), + AppSession.init(request, [env.SESSION_SECRET]), + ]); + + /* Create context objects required to use Hydrogen with your credentials and options */ + const hydrogenContext = createHydrogenContext({ + /* Environment variables from the fetch function */ + env, + /* Request object from the fetch function */ + request, + /* Cache API instance */ + cache, + /* Runtime utility in serverless environments */ + waitUntil, + session, + }); + + const handleRequest = createRequestHandler({ + build: remixBuild, + mode: process.env.NODE_ENV, + /* Inject the customer account client in the Remix context */ + getLoadContext: () => ({...hydrogenContext}), + }); + + const response = await handleRequest(request); + + if (session.isPending) { + response.headers.set('Set-Cookie', await session.commit()); + } + + return response; + }, +}; + +class AppSession { + isPending = false; + + 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.isPending = true; + this.session.unset(key); + } + + set(key, value) { + this.isPending = true; + this.session.set(key, value); + } + + commit() { + this.isPending = false; + return this.sessionStorage.commitSession(this.session); + } +} diff --git a/packages/hydrogen/src/createHydrogenContext.example.tsx b/packages/hydrogen/src/createHydrogenContext.example.tsx new file mode 100644 index 0000000000..9715b134fe --- /dev/null +++ b/packages/hydrogen/src/createHydrogenContext.example.tsx @@ -0,0 +1,98 @@ +import {createHydrogenContext, type HydrogenSession} 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: Env, executionContext: ExecutionContext) { + const waitUntil = executionContext.waitUntil.bind(executionContext); + const [cache, session] = await Promise.all([ + caches.open('hydrogen'), + AppSession.init(request, [env.SESSION_SECRET]), + ]); + + /* Create context objects required to use Hydrogen with your credentials and options */ + const hydrogenContext = createHydrogenContext({ + /* Environment variables from the fetch function */ + env, + /* Request object from the fetch function */ + request, + /* Cache API instance */ + cache, + /* Runtime utility in serverless environments */ + waitUntil, + session, + }); + + const handleRequest = createRequestHandler({ + build: remixBuild, + mode: process.env.NODE_ENV, + /* Inject the customer account client in the Remix context */ + getLoadContext: () => ({...hydrogenContext}), + }); + + const response = await handleRequest(request); + + if (session.isPending) { + response.headers.set('Set-Cookie', await session.commit()); + } + + return response; + }, +}; + +class AppSession implements HydrogenSession { + public isPending = false; + + 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.isPending = true; + this.session.unset(key); + } + + set(key: string, value: any) { + this.isPending = true; + this.session.set(key, value); + } + + commit() { + this.isPending = false; + return this.sessionStorage.commitSession(this.session); + } +} diff --git a/packages/hydrogen/src/createHydrogenContext.test.ts b/packages/hydrogen/src/createHydrogenContext.test.ts new file mode 100644 index 0000000000..9f1b97e6c3 --- /dev/null +++ b/packages/hydrogen/src/createHydrogenContext.test.ts @@ -0,0 +1,447 @@ +import {vi, describe, it, expect, afterEach, expectTypeOf} from 'vitest'; +import {createHydrogenContext} from './createHydrogenContext'; +import {createStorefrontClient} from './storefront'; +import {createCustomerAccountClient} from './customer/customer'; +import { + createCartHandler, + type HydrogenCart, + type HydrogenCartCustom, +} from './cart/createCartHandler'; +import {cartGetIdDefault} from './cart/cartGetIdDefault'; +import {cartSetIdDefault} from './cart/cartSetIdDefault'; +import type {CustomerAccount} from './customer/types'; +import type {HydrogenSession} from './types'; + +vi.mock('./storefront', async () => ({ + createStorefrontClient: vi.fn(() => ({ + storefront: {}, + })), +})); + +vi.mock('./customer/customer', async () => ({ + createCustomerAccountClient: vi.fn(() => ({isLoggedIn: true})), +})); + +vi.mock('./cart/createCartHandler', async () => ({ + createCartHandler: vi.fn(() => ({get: vi.fn()})), +})); + +vi.mock('./cart/cartGetIdDefault', async () => ({ + cartGetIdDefault: vi.fn(() => ({})), +})); + +vi.mock('./cart/cartSetIdDefault', async () => ({ + cartSetIdDefault: vi.fn(() => ({})), +})); + +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 mockEnv = { + SESSION_SECRET: 'SESSION_SECRET_value', + PUBLIC_STOREFRONT_API_TOKEN: 'PUBLIC_STOREFRONT_API_TOKEN_value', + PRIVATE_STOREFRONT_API_TOKEN: 'PRIVATE_STOREFRONT_API_TOKEN_value', + PUBLIC_STORE_DOMAIN: 'PUBLIC_STORE_DOMAIN_value', + PUBLIC_STOREFRONT_ID: 'PUBLIC_STOREFRONT_ID_value', + PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: + 'PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID_value', + PUBLIC_CUSTOMER_ACCOUNT_API_URL: 'PUBLIC_CUSTOMER_ACCOUNT_API_URL_value', + PUBLIC_CHECKOUT_DOMAIN: 'PUBLIC_CHECKOUT_DOMAIN_value', +}; + +const defaultOptions = { + env: mockEnv, + request: new Request('https://localhost'), + session: {} as HydrogenSession, +}; + +describe('createHydrogenContext', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('storefront client', () => { + it('returns storefront client', async () => { + const hydrogenContext = createHydrogenContext(defaultOptions); + + expect(hydrogenContext).toEqual( + expect.objectContaining({storefront: expect.any(Object)}), + ); + }); + + it('called createStorefrontClient with default values', async () => { + const mockRequest = new Request('https://localhost'); + + createHydrogenContext({ + ...defaultOptions, + request: mockRequest, + }); + + expect(vi.mocked(createStorefrontClient)).toHaveBeenCalledWith( + expect.objectContaining({ + publicStorefrontToken: mockEnv.PUBLIC_STOREFRONT_API_TOKEN, + privateStorefrontToken: mockEnv.PRIVATE_STOREFRONT_API_TOKEN, + storeDomain: mockEnv.PUBLIC_STORE_DOMAIN, + storefrontId: mockEnv.PUBLIC_STOREFRONT_ID, + storefrontHeaders: expect.anything(), + }), + ); + }); + + it('called createStorefrontClient with overwrite default values', async () => { + const mockStorefrontHeaders = { + requestGroupId: 'requestGroupId value', + buyerIp: 'buyerIp value', + cookie: 'cookie value', + purpose: 'purpose value', + }; + + createHydrogenContext({ + ...defaultOptions, + storefront: { + headers: mockStorefrontHeaders, + }, + }); + + expect(vi.mocked(createStorefrontClient)).toHaveBeenCalledWith( + expect.objectContaining({ + storefrontHeaders: mockStorefrontHeaders, + }), + ); + }); + + it('called createStorefrontClient with values that does not have default', async () => { + createHydrogenContext({ + ...defaultOptions, + i18n: {language: 'EN', country: 'CA'}, + }); + + expect(vi.mocked(createStorefrontClient)).toHaveBeenCalledWith( + expect.objectContaining({ + i18n: {language: 'EN', country: 'CA'}, + }), + ); + }); + + it('does not call getStorefrontHeaders when storefrontHeaders is provided', async () => { + const mockeStorefrontHeaders = { + requestGroupId: 'requestGroupId value', + buyerIp: 'buyerIp value', + cookie: 'cookie value', + purpose: 'purpose', + }; + + createHydrogenContext({ + ...defaultOptions, + storefront: {headers: mockeStorefrontHeaders}, + }); + + expect(vi.mocked(createStorefrontClient)).toHaveBeenCalledWith( + expect.objectContaining({ + storefrontHeaders: mockeStorefrontHeaders, + }), + ); + }); + + it('called createStorefrontClient with renamed apiVersion key', async () => { + const mockApiVersion = 'new storefrontApiVersion'; + + createHydrogenContext({ + ...defaultOptions, + storefront: { + apiVersion: mockApiVersion, + }, + }); + + expect(vi.mocked(createStorefrontClient)).toHaveBeenCalledWith( + expect.objectContaining({ + storefrontApiVersion: mockApiVersion, + }), + ); + }); + }); + + describe('customerAccount client', () => { + it('called createCustomerAccountClient with default values', async () => { + createHydrogenContext(defaultOptions); + + expect(vi.mocked(createCustomerAccountClient)).toHaveBeenCalledWith( + expect.objectContaining({ + customerAccountId: mockEnv.PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID, + customerAccountUrl: mockEnv.PUBLIC_CUSTOMER_ACCOUNT_API_URL, + }), + ); + }); + + it('called createCustomerAccountClient with values that does not have default', async () => { + const mockAuthUrl = 'customerAccountId overwrite'; + createHydrogenContext({ + ...defaultOptions, + customerAccount: { + authUrl: mockAuthUrl, + }, + }); + + expect(vi.mocked(createCustomerAccountClient)).toHaveBeenCalledWith( + expect.objectContaining({ + authUrl: mockAuthUrl, + }), + ); + }); + + it('called createCustomerAccountClient with renamed apiVersion key', async () => { + const mockApiVersion = 'new customerApiVersion'; + + createHydrogenContext({ + ...defaultOptions, + customerAccount: { + apiVersion: mockApiVersion, + }, + }); + + expect(vi.mocked(createCustomerAccountClient)).toHaveBeenCalledWith( + expect.objectContaining({ + customerApiVersion: mockApiVersion, + }), + ); + }); + }); + + describe('cart client', () => { + it('returns cart client', async () => { + const hydrogenContext = createHydrogenContext(defaultOptions); + + expect(hydrogenContext).toStrictEqual( + expect.objectContaining({cart: expect.any(Object)}), + ); + }); + + it('called createCartHandler with default values', async () => { + const mockRequest = new Request('https://localhost'); + + const hydrogenContext = createHydrogenContext({ + ...defaultOptions, + request: mockRequest, + }); + + expect(vi.mocked(createCartHandler)).toHaveBeenCalledWith( + expect.objectContaining({ + storefront: hydrogenContext.storefront, + customerAccount: hydrogenContext.customerAccount, + getCartId: expect.anything(), + setCartId: expect.anything(), + }), + ); + + expect(vi.mocked(cartGetIdDefault)).toHaveBeenCalledOnce(); + expect(vi.mocked(cartGetIdDefault)).toHaveBeenCalledWith( + mockRequest.headers, + ); + + expect(vi.mocked(cartSetIdDefault)).toHaveBeenCalledOnce(); + expect(vi.mocked(cartSetIdDefault)).toHaveBeenCalledWith(); + }); + + it('called createCartHandler with overwrite default values', async () => { + const mockGetCartId = () => { + return 'mock getCartId'; + }; + + createHydrogenContext({ + ...defaultOptions, + cart: { + getId: mockGetCartId, + }, + }); + + expect(vi.mocked(createCartHandler)).toHaveBeenCalledWith( + expect.objectContaining({ + getCartId: mockGetCartId, + }), + ); + }); + + it('called createCartHandler with values that does not have default', async () => { + const mockCartQueryFragment = 'mock cartQueryFragment'; + + createHydrogenContext({ + ...defaultOptions, + cart: { + queryFragment: mockCartQueryFragment, + }, + }); + + expect(vi.mocked(createCartHandler)).toHaveBeenCalledWith( + expect.objectContaining({ + cartQueryFragment: mockCartQueryFragment, + }), + ); + }); + + it('does not call cartGetIdDefault when getCartId is provided', async () => { + const mockGetCartId = () => { + return 'mock getCartId'; + }; + + createHydrogenContext({ + ...defaultOptions, + cart: { + getId: mockGetCartId, + }, + }); + + expect(vi.mocked(createCartHandler)).toHaveBeenCalledWith( + expect.objectContaining({ + getCartId: mockGetCartId, + }), + ); + + expect(vi.mocked(cartGetIdDefault)).not.toHaveBeenCalled(); + }); + + it('does not call cartSetIdDefault when setCartId is provided', async () => { + const mockSetCartId = () => { + return new Headers(); + }; + + createHydrogenContext({ + ...defaultOptions, + cart: { + setId: mockSetCartId, + }, + }); + + expect(vi.mocked(createCartHandler)).toHaveBeenCalledWith( + expect.objectContaining({ + setCartId: mockSetCartId, + }), + ); + + expect(vi.mocked(cartSetIdDefault)).not.toHaveBeenCalled(); + }); + + it('called createCartHandler with renamed queryFragment key', async () => { + const mockQueryFragment = 'new queryFragment'; + + createHydrogenContext({ + ...defaultOptions, + cart: { + queryFragment: mockQueryFragment, + }, + }); + + expect(vi.mocked(createCartHandler)).toHaveBeenCalledWith( + expect.objectContaining({ + cartQueryFragment: mockQueryFragment, + }), + ); + }); + + it('called createCartHandler with renamed mutateFragment key', async () => { + const mockMutateFragment = 'new mutateFragment'; + + createHydrogenContext({ + ...defaultOptions, + cart: { + mutateFragment: mockMutateFragment, + }, + }); + + expect(vi.mocked(createCartHandler)).toHaveBeenCalledWith( + expect.objectContaining({ + cartMutateFragment: mockMutateFragment, + }), + ); + }); + + describe('cart return based on options', () => { + it('returns cart handler with HydrogenCart if there cart.customMethods key does not exist', async () => { + const hydrogenContext = createHydrogenContext(defaultOptions); + + expect(hydrogenContext).toHaveProperty('cart'); + expectTypeOf(hydrogenContext.cart).toEqualTypeOf(); + }); + + it('returns cart handler with HydrogenCart if there cart.customMethods is undefined', async () => { + const hydrogenContext = createHydrogenContext({ + ...defaultOptions, + cart: {customMethods: undefined}, + }); + + expect(hydrogenContext).toHaveProperty('cart'); + expectTypeOf(hydrogenContext.cart).toEqualTypeOf(); + }); + + it('returns cart handler with HydrogenCartCustom if there cart.customMethods is defined', async () => { + const customMethods = {testMethod: () => {}}; + + const hydrogenContext = createHydrogenContext({ + ...defaultOptions, + cart: {customMethods}, + }); + + expect(hydrogenContext).toHaveProperty('cart'); + expectTypeOf(hydrogenContext.cart).toEqualTypeOf< + HydrogenCartCustom + >(); + }); + }); + }); + + describe('env', () => { + it('returns env as it was passed in', async () => { + const customizedEnv = {...mockEnv, extraKey: 'extra key value'}; + + const hydrogenContext = createHydrogenContext({ + ...defaultOptions, + env: customizedEnv, + }); + + expect(hydrogenContext).toStrictEqual( + expect.objectContaining({env: customizedEnv}), + ); + }); + }); + + describe('waitUntil', () => { + it('returns waitUntil as it was passed in', async () => { + const mockWaitUntil = vi.fn(); + const second = vi.fn(); + + const hydrogenContext = createHydrogenContext({ + ...defaultOptions, + waitUntil: mockWaitUntil, + }); + + expect(hydrogenContext).toStrictEqual( + expect.objectContaining({waitUntil: mockWaitUntil}), + ); + }); + }); + + describe('session', () => { + it('returns waitUntil as it was passed in', async () => { + const mockSession = {} as HydrogenSession; + + const hydrogenContext = createHydrogenContext({ + ...defaultOptions, + session: mockSession, + }); + + expect(hydrogenContext).toStrictEqual( + expect.objectContaining({session: mockSession}), + ); + }); + }); +}); diff --git a/packages/hydrogen/src/createHydrogenContext.ts b/packages/hydrogen/src/createHydrogenContext.ts new file mode 100644 index 0000000000..248218056b --- /dev/null +++ b/packages/hydrogen/src/createHydrogenContext.ts @@ -0,0 +1,302 @@ +import { + createStorefrontClient, + type CreateStorefrontClientOptions, + type StorefrontClient, + type I18nBase, +} from './storefront'; +import {createCustomerAccountClient} from './customer/customer'; +import { + type CustomerAccountOptions, + type CustomerAccount, +} from './customer/types'; +import { + createCartHandler, + type CartHandlerOptions, + type CustomMethodsBase, + type HydrogenCart, + type HydrogenCartCustom, +} from './cart/createCartHandler'; +import {cartGetIdDefault} from './cart/cartGetIdDefault'; +import {cartSetIdDefault} from './cart/cartSetIdDefault'; +import type { + HydrogenEnv, + WaitUntil, + HydrogenSession, + StorefrontHeaders, +} from './types'; +import {type CrossRuntimeRequest, getHeader} from './utils/request'; + +export type HydrogenContextOptions< + TSession extends HydrogenSession = HydrogenSession, + TCustomMethods extends CustomMethodsBase | undefined = undefined, + TI18n extends I18nBase = I18nBase, + TEnv extends HydrogenEnv = Env, +> = { + /* Environment variables from the fetch function */ + env: TEnv; + /* Request object from the fetch function */ + request: CrossRuntimeRequest; + /** An instance that implements the [Cache API](https://developer.mozilla.org/en-US/docs/Web/API/Cache) */ + cache?: Cache; + /** 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?: WaitUntil; + /** Any cookie implementation. 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: TSession; + /** An object containing a country code and language code */ + i18n?: TI18n; + /** Whether it should print GraphQL errors automatically. Defaults to true */ + logErrors?: boolean | ((error?: Error) => boolean); + /** Storefront client overwrite options. See documentation for createStorefrontClient for more information. */ + storefront?: { + /** Storefront API headers. Default values set from request header. */ + headers?: CreateStorefrontClientOptions['storefrontHeaders']; + /** Override the Storefront API version for this query. */ + apiVersion?: CreateStorefrontClientOptions['storefrontApiVersion']; + }; + /** Customer Account client overwrite options. See documentation for createCustomerAccountClient for more information. */ + customerAccount?: { + /** Override the version of the API */ + apiVersion?: CustomerAccountOptions['customerApiVersion']; + /** This is the route in your app that authorizes the customer after logging in. Make sure to call `customer.authorize()` within the loader on this route. It defaults to `/account/authorize`. */ + authUrl?: CustomerAccountOptions['authUrl']; + /** Use this method to overwrite the default logged-out redirect behavior. The default handler [throws a redirect](https://remix.run/docs/en/main/utils/redirect#:~:text=!session) to `/account/login` with current path as `return_to` query param. */ + customAuthStatusHandler?: CustomerAccountOptions['customAuthStatusHandler']; + /** UNSTABLE feature, this will eventually goes away. If true then we will exchange a customerAccessToken for a storefrontCustomerAccessToken. */ + unstableB2b?: CustomerAccountOptions['unstableB2b']; + }; + /** Cart handler overwrite options. See documentation for createCartHandler for more information. */ + cart?: { + /** A function that returns the cart id in the form of `gid://shopify/Cart/c1-123`. */ + getId?: CartHandlerOptions['getCartId']; + /** A function that sets the cart ID. */ + setId?: CartHandlerOptions['setCartId']; + /** + * The cart query fragment used by `cart.get()`. + * See the [example usage](/docs/api/hydrogen/2024-07/utilities/createcarthandler#example-cart-fragments) in the documentation. + */ + queryFragment?: CartHandlerOptions['cartQueryFragment']; + /** + * The cart mutation fragment used in most mutation requests, except for `setMetafields` and `deleteMetafield`. + * See the [example usage](/docs/api/hydrogen/2024-07/utilities/createcarthandler#example-cart-fragments) in the documentation. + */ + mutateFragment?: CartHandlerOptions['cartMutateFragment']; + /** + * Define custom methods or override existing methods for your cart API instance. + * See the [example usage](/docs/api/hydrogen/2024-07/utilities/createcarthandler#example-custom-methods) in the documentation. + */ + customMethods?: TCustomMethods; + }; +}; + +export interface HydrogenContext< + TSession extends HydrogenSession = HydrogenSession, + TCustomMethods extends CustomMethodsBase | undefined = undefined, + TI18n extends I18nBase = I18nBase, + TEnv extends HydrogenEnv = Env, +> { + /** A GraphQL client for querying the [Storefront API](https://shopify.dev/docs/api/storefront). */ + storefront: StorefrontClient['storefront']; + /** 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. */ + customerAccount: CustomerAccount; + /** A collection of utilities used to interact with the cart. */ + cart: TCustomMethods extends CustomMethodsBase + ? HydrogenCartCustom + : HydrogenCart; + /* Request object from the fetch function */ + env: TEnv; + /** 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?: WaitUntil; + /** Any cookie implementation. 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: TSession; +} + +// Since HydrogenContext uses a conditional type with a free type parameter, +// TS cannot definitively determine what the return type should be within the function body +// HydrogenContextOverloads is use to restore type assertions so we don't need to do type casting +export interface HydrogenContextOverloads< + TSession extends HydrogenSession, + TCustomMethods extends CustomMethodsBase, + TI18n extends I18nBase = I18nBase, + TEnv extends HydrogenEnv = Env, +> { + storefront: StorefrontClient['storefront']; + customerAccount: CustomerAccount; + cart: HydrogenCart | HydrogenCartCustom; + env: TEnv; + waitUntil?: WaitUntil; + session: TSession; +} + +// type for createHydrogenContext methods +export function createHydrogenContext< + TSession extends HydrogenSession = HydrogenSession, + TCustomMethods extends CustomMethodsBase | undefined = undefined, + TI18n extends I18nBase = I18nBase, + TEnv extends HydrogenEnv = Env, +>( + options: HydrogenContextOptions, +): HydrogenContext; + +export function createHydrogenContext< + TSession extends HydrogenSession, + TCustomMethods extends CustomMethodsBase, + TI18n extends I18nBase, + TEnv extends HydrogenEnv = Env, +>( + options: HydrogenContextOptions, +): HydrogenContextOverloads { + const { + env, + request, + cache, + waitUntil, + i18n, + session, + logErrors, + storefront: storefrontOptions = {}, + customerAccount: customerAccountOptions, + cart: cartOptions = {}, + } = options; + + if (!session) { + console.warn( + `[h2:warn:createHydrogenContext] A session object is required to create hydrogen context.`, + ); + } + + /** + * Create Hydrogen's Storefront client. + */ + const {storefront} = createStorefrontClient({ + // share options + cache, + waitUntil, + i18n, + logErrors, + + // storefrontOptions + storefrontHeaders: + storefrontOptions.headers || getStorefrontHeaders(request), + storefrontApiVersion: storefrontOptions.apiVersion, + + // defaults + storefrontId: env.PUBLIC_STOREFRONT_ID, + storeDomain: env.PUBLIC_STORE_DOMAIN, + privateStorefrontToken: env.PRIVATE_STOREFRONT_API_TOKEN, + publicStorefrontToken: env.PUBLIC_STOREFRONT_API_TOKEN, + }); + + const customerAccount = createCustomerAccountClient({ + // share options + session, + request, + waitUntil, + logErrors, + + // customerAccountOptions + customerApiVersion: customerAccountOptions?.apiVersion, + authUrl: customerAccountOptions?.authUrl, + customAuthStatusHandler: customerAccountOptions?.customAuthStatusHandler, + unstableB2b: customerAccountOptions?.unstableB2b, + + // defaults + customerAccountId: env.PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID, + customerAccountUrl: env.PUBLIC_CUSTOMER_ACCOUNT_API_URL, + }); + + /* + * Create a cart handler that will be used to + * create and update the cart in the session. + */ + const cart = createCartHandler({ + // cartOptions + getCartId: cartOptions.getId || cartGetIdDefault(request.headers), + setCartId: cartOptions.setId || cartSetIdDefault(), + cartQueryFragment: cartOptions.queryFragment, + cartMutateFragment: cartOptions.mutateFragment, + customMethods: cartOptions.customMethods, + + // defaults + storefront, + customerAccount, + }); + + return { + storefront, + customerAccount, + cart, + env, + waitUntil, + session, + }; +} + +function getStorefrontHeaders(request: CrossRuntimeRequest): StorefrontHeaders { + return { + requestGroupId: getHeader(request, 'request-id'), + buyerIp: getHeader(request, 'oxygen-buyer-ip'), + cookie: getHeader(request, 'cookie'), + purpose: getHeader(request, 'purpose'), + }; +} + +export type HydrogenContextOptionsForDocs< + TSession extends HydrogenSession = HydrogenSession, + TI18n extends I18nBase = I18nBase, + TEnv extends HydrogenEnv = Env, +> = { + /* Environment variables from the fetch function */ + env: TEnv; + /* Request object from the fetch function */ + request: CrossRuntimeRequest; + /** An instance that implements the [Cache API](https://developer.mozilla.org/en-US/docs/Web/API/Cache) */ + cache?: Cache; + /** 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?: WaitUntil; + /** Any cookie implementation. 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: TSession; + /** An object containing a country code and language code */ + i18n?: TI18n; + /** Whether it should print GraphQL errors automatically. Defaults to true */ + logErrors?: boolean | ((error?: Error) => boolean); + /** Storefront client overwrite options. See documentation for createStorefrontClient for more information. */ + storefront?: { + /** Storefront API headers. Default values set from request header. */ + headers?: StorefrontHeaders; + /** Override the Storefront API version for this query. */ + apiVersion?: string; + }; + /** Customer Account client overwrite options. See documentation for createCustomerAccountClient for more information. */ + customerAccount?: { + /** Override the version of the API */ + apiVersion?: string; + /** This is the route in your app that authorizes the customer after logging in. Make sure to call `customer.authorize()` within the loader on this route. It defaults to `/account/authorize`. */ + authUrl?: string; + /** Use this method to overwrite the default logged-out redirect behavior. The default handler [throws a redirect](https://remix.run/docs/en/main/utils/redirect#:~:text=!session) to `/account/login` with current path as `return_to` query param. */ + customAuthStatusHandler?: () => Response | NonNullable | null; + /** UNSTABLE feature, this will eventually goes away. If true then we will exchange customerAccessToken for storefrontCustomerAccessToken. */ + unstableB2b?: boolean; + }; + /** Cart handler overwrite options. See documentation for createCartHandler for more information. */ + cart?: { + /** A function that returns the cart id in the form of `gid://shopify/Cart/c1-123`. */ + getId?: () => string | undefined; + /** A function that sets the cart ID. */ + setId?: (cartId: string) => Headers; + /** + * The cart query fragment used by `cart.get()`. + * See the [example usage](/docs/api/hydrogen/2024-07/utilities/createcarthandler#example-cart-fragments) in the documentation. + */ + queryFragment?: string; + /** + * The cart mutation fragment used in most mutation requests, except for `setMetafields` and `deleteMetafield`. + * See the [example usage](/docs/api/hydrogen/2024-07/utilities/createcarthandler#example-cart-fragments) in the documentation. + */ + mutateFragment?: string; + /** + * Define custom methods or override existing methods for your cart API instance. + * See the [example usage](/docs/api/hydrogen/2024-07/utilities/createcarthandler#example-custom-methods) in the documentation. + */ + customMethods?: Record; + }; +}; diff --git a/packages/hydrogen/src/customer/customer.ts b/packages/hydrogen/src/customer/customer.ts index 10b2fa78ce..946c593edc 100644 --- a/packages/hydrogen/src/customer/customer.ts +++ b/packages/hydrogen/src/customer/customer.ts @@ -83,6 +83,12 @@ export function createCustomerAccountClient({ ); } + if (!session) { + console.warn( + `[h2:warn:createCustomerAccountClient] session is required to use Customer Account API. Ensure the session object passed in exist.`, + ); + } + if (!request?.url) { throw new Error( '[h2:error:createCustomerAccountClient] The request object does not contain a URL.', diff --git a/packages/hydrogen/src/index.ts b/packages/hydrogen/src/index.ts index 4e6718fb83..c74f1367a8 100644 --- a/packages/hydrogen/src/index.ts +++ b/packages/hydrogen/src/index.ts @@ -162,4 +162,9 @@ export type { StorefrontApiResponsePartial, } from '@shopify/hydrogen-react'; -export type {HydrogenSessionData, HydrogenSession} from './types'; +export type {HydrogenSessionData, HydrogenSession, HydrogenEnv} from './types'; + +export { + createHydrogenContext, + type HydrogenContext, +} from './createHydrogenContext'; diff --git a/packages/hydrogen/src/storefront.ts b/packages/hydrogen/src/storefront.ts index 8935231f4f..832fd33ba0 100644 --- a/packages/hydrogen/src/storefront.ts +++ b/packages/hydrogen/src/storefront.ts @@ -53,7 +53,7 @@ import { withSyncStack, type StackInfo, } from './utils/callsites'; -import type {WaitUntil} from './types'; +import type {WaitUntil, StorefrontHeaders} from './types'; export type I18nBase = { language: LanguageCode; @@ -179,17 +179,6 @@ type HydrogenClientProps = { export type CreateStorefrontClientOptions = HydrogenClientProps & StorefrontClientProps; -type StorefrontHeaders = { - /** A unique ID that correlates all sub-requests together. */ - requestGroupId: string | null; - /** The IP address of the client. */ - buyerIp: string | null; - /** The cookie header from the client */ - cookie: string | null; - /** The purpose header value for debugging */ - purpose: string | null; -}; - type StorefrontQueryOptions = StorefrontCommonExtraParams & { query: string; mutation?: never; diff --git a/packages/hydrogen/src/types.d.ts b/packages/hydrogen/src/types.d.ts index 88be5448c5..8fa117be30 100644 --- a/packages/hydrogen/src/types.d.ts +++ b/packages/hydrogen/src/types.d.ts @@ -10,7 +10,6 @@ import { BUYER_SESSION_KEY, } from './customer/constants'; import type {BuyerInput} from '@shopify/hydrogen-react/storefront-api-types'; - export interface HydrogenSessionData { [CUSTOMER_ACCOUNT_SESSION_KEY]: { accessToken?: string; @@ -41,6 +40,28 @@ export interface HydrogenSession< export type WaitUntil = (promise: Promise) => void; +export interface HydrogenEnv { + SESSION_SECRET: string; + PUBLIC_STOREFRONT_API_TOKEN: string; + PRIVATE_STOREFRONT_API_TOKEN: string; + PUBLIC_STORE_DOMAIN: string; + PUBLIC_STOREFRONT_ID: string; + PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string; + PUBLIC_CUSTOMER_ACCOUNT_API_URL: string; + PUBLIC_CHECKOUT_DOMAIN: string; +} + +export type StorefrontHeaders = { + /** A unique ID that correlates all sub-requests together. */ + requestGroupId: string | null; + /** The IP address of the client. */ + buyerIp: string | null; + /** The cookie header from the client */ + cookie: string | null; + /** The purpose header value for debugging */ + purpose: string | null; +}; + declare global { interface Window { privacyBanner: PrivacyBanner; diff --git a/packages/hydrogen/src/utils/request.ts b/packages/hydrogen/src/utils/request.ts index 6b81279ebd..e6e47ac418 100644 --- a/packages/hydrogen/src/utils/request.ts +++ b/packages/hydrogen/src/utils/request.ts @@ -8,7 +8,14 @@ export type CrossRuntimeRequest = { }; export function getHeader(request: CrossRuntimeRequest, key: string) { - const value = request.headers?.get?.(key) ?? request.headers?.[key]; + return getHeaderValue(request.headers, key); +} + +export function getHeaderValue( + headers: CrossRuntimeRequest['headers'], + key: string, +) { + const value = headers?.get?.(key) ?? headers?.[key]; return typeof value === 'string' ? value : null; } diff --git a/templates/skeleton/app/lib/context.ts b/templates/skeleton/app/lib/context.ts new file mode 100644 index 0000000000..c424c51116 --- /dev/null +++ b/templates/skeleton/app/lib/context.ts @@ -0,0 +1,43 @@ +import {createHydrogenContext} from '@shopify/hydrogen'; +import {AppSession} from '~/lib/session'; +import {CART_QUERY_FRAGMENT} from '~/lib/fragments'; + +/** + * The context implementation is separate from server.ts + * so that type can be extracted for AppLoadContext + * */ +export async function createAppLoadContext( + request: Request, + env: Env, + executionContext: ExecutionContext, +) { + /** + * Open a cache instance in the worker and a custom session instance. + */ + if (!env?.SESSION_SECRET) { + throw new Error('SESSION_SECRET environment variable is not set'); + } + + const waitUntil = executionContext.waitUntil.bind(executionContext); + const [cache, session] = await Promise.all([ + caches.open('hydrogen'), + AppSession.init(request, [env.SESSION_SECRET]), + ]); + + const hydrogenContext = createHydrogenContext({ + env, + request, + cache, + waitUntil, + session, + i18n: {language: 'EN', country: 'US'}, + cart: { + queryFragment: CART_QUERY_FRAGMENT, + }, + }); + + return { + ...hydrogenContext, + // declare additional Remix loader context + }; +} diff --git a/templates/skeleton/env.d.ts b/templates/skeleton/env.d.ts index b14ff8eaa2..c9538bf4f6 100644 --- a/templates/skeleton/env.d.ts +++ b/templates/skeleton/env.d.ts @@ -6,12 +6,11 @@ import '@total-typescript/ts-reset'; import type { - Storefront, - CustomerAccount, - HydrogenCart, + HydrogenContext, HydrogenSessionData, + HydrogenEnv, } from '@shopify/hydrogen'; -import type {AppSession} from '~/lib/session'; +import type {createAppLoadContext} from '~/lib/context'; declare global { /** @@ -19,36 +18,18 @@ declare global { */ const process: {env: {NODE_ENV: 'production' | 'development'}}; - /** - * Declare expected Env parameter in fetch handler. - */ - interface Env { - SESSION_SECRET: string; - PUBLIC_STOREFRONT_API_TOKEN: string; - PRIVATE_STOREFRONT_API_TOKEN: string; - PUBLIC_STORE_DOMAIN: string; - PUBLIC_STOREFRONT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string; - PUBLIC_CUSTOMER_ACCOUNT_API_URL: string; - PUBLIC_CHECKOUT_DOMAIN: string; + interface Env extends HydrogenEnv { + // declare additional Env parameter use in the fetch handler and Remix loader context here } } declare module '@shopify/remix-oxygen' { - /** - * Declare local additions to the Remix loader context. - */ - interface AppLoadContext { - env: Env; - cart: HydrogenCart; - storefront: Storefront; - customerAccount: CustomerAccount; - session: AppSession; - waitUntil: ExecutionContext['waitUntil']; + interface AppLoadContext + extends Awaited> { + // to change context type, change the return of createAppLoadContext() instead } - /** - * Declare local additions to the Remix session data. - */ - interface SessionData extends HydrogenSessionData {} + interface SessionData extends HydrogenSessionData { + // declare local additions to the Remix session data here + } } diff --git a/templates/skeleton/server.ts b/templates/skeleton/server.ts index 448553607c..85058582ea 100644 --- a/templates/skeleton/server.ts +++ b/templates/skeleton/server.ts @@ -1,21 +1,9 @@ // @ts-ignore // Virtual entry point for the app import * as remixBuild from 'virtual:remix/server-build'; -import { - cartGetIdDefault, - cartSetIdDefault, - createCartHandler, - createStorefrontClient, - storefrontRedirect, - createCustomerAccountClient, -} from '@shopify/hydrogen'; -import { - createRequestHandler, - getStorefrontHeaders, - type AppLoadContext, -} from '@shopify/remix-oxygen'; -import {AppSession} from '~/lib/session'; -import {CART_QUERY_FRAGMENT} from '~/lib/fragments'; +import {storefrontRedirect} from '@shopify/hydrogen'; +import {createRequestHandler} from '@shopify/remix-oxygen'; +import {createAppLoadContext} from '~/lib/context'; /** * Export a fetch handler in module format. @@ -27,55 +15,11 @@ export default { executionContext: ExecutionContext, ): Promise { try { - /** - * Open a cache instance in the worker and a custom session instance. - */ - if (!env?.SESSION_SECRET) { - throw new Error('SESSION_SECRET environment variable is not set'); - } - - const waitUntil = executionContext.waitUntil.bind(executionContext); - const [cache, session] = await Promise.all([ - caches.open('hydrogen'), - AppSession.init(request, [env.SESSION_SECRET]), - ]); - - /** - * Create Hydrogen's Storefront client. - */ - const {storefront} = createStorefrontClient({ - cache, - waitUntil, - i18n: {language: 'EN', country: 'US'}, - publicStorefrontToken: env.PUBLIC_STOREFRONT_API_TOKEN, - privateStorefrontToken: env.PRIVATE_STOREFRONT_API_TOKEN, - storeDomain: env.PUBLIC_STORE_DOMAIN, - storefrontId: env.PUBLIC_STOREFRONT_ID, - storefrontHeaders: getStorefrontHeaders(request), - }); - - /** - * Create a client for Customer Account API. - */ - const customerAccount = createCustomerAccountClient({ - waitUntil, + const appLoadContext = await createAppLoadContext( request, - session, - customerAccountId: env.PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID, - customerAccountUrl: env.PUBLIC_CUSTOMER_ACCOUNT_API_URL, - }); - - /* - * Create a cart handler that will be used to - * create and update the cart in the session. - */ - const cart = createCartHandler({ - storefront, - customerAccount, - getCartId: cartGetIdDefault(request.headers), - setCartId: cartSetIdDefault(), - cartQueryFragment: CART_QUERY_FRAGMENT, - }); + env, + executionContext, + ); /** * Create a Remix request handler and pass @@ -84,20 +28,16 @@ export default { const handleRequest = createRequestHandler({ build: remixBuild, mode: process.env.NODE_ENV, - getLoadContext: (): AppLoadContext => ({ - session, - storefront, - customerAccount, - cart, - env, - waitUntil, - }), + getLoadContext: () => appLoadContext, }); const response = await handleRequest(request); - if (session.isPending) { - response.headers.set('Set-Cookie', await session.commit()); + if (appLoadContext.session.isPending) { + response.headers.set( + 'Set-Cookie', + await appLoadContext.session.commit(), + ); } if (response.status === 404) { @@ -106,7 +46,11 @@ export default { * If the redirect doesn't exist, then `storefrontRedirect` * will pass through the 404 response. */ - return storefrontRedirect({request, response, storefront}); + return storefrontRedirect({ + request, + response, + storefront: appLoadContext.storefront, + }); } return response;