@shopify/hydrogen 2026.10.0-preview.5 → 2026.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.mjs +4 -5
- package/dist/core/request-context.mjs +1 -1
- package/dist/core/request-context.mjs.map +1 -1
- package/dist/customer-account/client.mjs +1 -1
- package/dist/customer-account/session.mjs +1 -1
- package/dist/development/core/request-context.mjs +1 -1
- package/dist/development/core/request-context.mjs.map +1 -1
- package/package.json +1 -1
- package/skills/hydrogen-setup/steps/1-inspect-app.md +2 -2
- package/skills/migrate-to-hydrogen-2026-10/SKILL.md +332 -0
- package/skills/migrate-to-hydrogen-2026-10/references/analytics.md +24 -0
- package/skills/migrate-to-hydrogen-2026-10/references/cart.md +9 -0
- package/skills/migrate-to-hydrogen-2026-10/references/customer-account.md +10 -0
- package/skills/migrate-to-hydrogen-2026-10/references/dependencies-and-tooling.md +10 -0
- package/skills/migrate-to-hydrogen-2026-10/references/optional-subsystems.md +25 -0
- package/skills/migrate-to-hydrogen-2026-10/references/routing-and-loaders.md +10 -0
- package/skills/migrate-to-hydrogen-2026-10/references/sitemap-and-seo.md +5 -0
- package/skills/migrate-to-hydrogen-2026-10/references/typescript-and-gql-tada.md +7 -0
- package/skills/migrate-to-hydrogen-2026-10/references/ui-and-components.md +9 -0
package/dist/cli/index.mjs
CHANGED
|
@@ -905,7 +905,6 @@ async function syncSkills(options = {}) {
|
|
|
905
905
|
//#endregion
|
|
906
906
|
//#region src/cli/setup.ts
|
|
907
907
|
const PACKAGE_NAME = "@shopify/hydrogen";
|
|
908
|
-
const PACKAGE_INSTALL_SPEC = `${PACKAGE_NAME}@preview`;
|
|
909
908
|
const PACKAGE_JSON_FILE_NAME = "package.json";
|
|
910
909
|
const SUCCESS_EXIT_CODE = 0;
|
|
911
910
|
const PACKAGE_MANAGER_LOCKFILES = [
|
|
@@ -916,10 +915,10 @@ const PACKAGE_MANAGER_LOCKFILES = [
|
|
|
916
915
|
["bun", "bun.lock"]
|
|
917
916
|
];
|
|
918
917
|
const INSTALL_ARGS = {
|
|
919
|
-
pnpm: ["add",
|
|
920
|
-
npm: ["install",
|
|
921
|
-
yarn: ["add",
|
|
922
|
-
bun: ["add",
|
|
918
|
+
pnpm: ["add", PACKAGE_NAME],
|
|
919
|
+
npm: ["install", PACKAGE_NAME],
|
|
920
|
+
yarn: ["add", PACKAGE_NAME],
|
|
921
|
+
bun: ["add", PACKAGE_NAME]
|
|
923
922
|
};
|
|
924
923
|
function getStringRecord(value) {
|
|
925
924
|
if (!isObjectRecord(value)) return void 0;
|
|
@@ -84,7 +84,7 @@ function applyStorefrontRequestHeaders(context, headers) {
|
|
|
84
84
|
headers.set(SDK_VARIANT_HEADER, "hydrogen");
|
|
85
85
|
headers.set(SDK_VARIANT_SOURCE_HEADER, "kit");
|
|
86
86
|
headers.set(SDK_VERSION_HEADER, STOREFRONT_API_VERSION);
|
|
87
|
-
headers.set(HYDROGEN_VERSION_HEADER, "2026.10.
|
|
87
|
+
headers.set(HYDROGEN_VERSION_HEADER, "2026.10.1");
|
|
88
88
|
headers.set(REQUEST_GROUP_ID_HEADER, context.requestGroupId);
|
|
89
89
|
if (context.cookie) headers.set("cookie", context.cookie);
|
|
90
90
|
else headers.delete("cookie");
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"request-context.mjs","names":[],"sources":["../../src/core/request-context.ts"],"sourcesContent":["import type {\n CountryCode as CustomerAccountCountryCode,\n LanguageCode as CustomerAccountLanguageCode,\n} from \"../graphql/generated/customer-account-api-types\";\nimport type {\n CountryCode as StorefrontCountryCode,\n LanguageCode as StorefrontLanguageCode,\n} from \"../graphql/generated/storefront-api-types\";\nimport { STOREFRONT_API_VERSION } from \"./constants\";\nimport {\n applyPrivateResponseCacheHeaders,\n CONSENT_MANAGEMENT_HEADER,\n HYDROGEN_VERSION_HEADER,\n REQUEST_GROUP_ID_HEADER,\n SDK_VARIANT_HEADER,\n SDK_VARIANT_SOURCE_HEADER,\n SDK_VERSION_HEADER,\n SEC_GPC_HEADER,\n SHOPIFY_STOREFRONT_ORIGIN_HEADER,\n STOREFRONT_URL_HEADER,\n} from \"./headers\";\nimport { normalizePathPrefix } from \"./standard-routes/path\";\n\nconst SHOPIFY_ESSENTIAL_COOKIE = \"_shopify_essential\";\n\n/** The incoming request's headers, plus its method, abort signal, and URL when the framework provides them. */\ntype StorefrontRequest = Pick<Request, \"headers\"> &\n Partial<Pick<Request, \"method\" | \"signal\" | \"url\">>;\n\n/** A language code that both the Storefront API and the Customer Account API accept. */\nexport type ShopifyLanguageCode = Extract<StorefrontLanguageCode, CustomerAccountLanguageCode>;\n/** A country code that both the Storefront API and the Customer Account API accept. */\nexport type ShopifyCountryCode = Extract<StorefrontCountryCode, CustomerAccountCountryCode>;\n\n/** The language, country, and optional localized path prefix for a request. */\nexport type I18nConfig = {\n /** The request's language. */\n language: ShopifyLanguageCode;\n /** The request's country. */\n country: ShopifyCountryCode;\n /**\n * The URL prefix for localized routes, such as `/es-es`. Hydrogen adds the prefix to standard route redirects and product variant URLs.\n */\n pathPrefix?: string;\n};\n\n/** The request's locale, with a path prefix that starts with `/` or is an empty string. */\ntype NormalizedI18nConfig<I18n extends I18nConfig = I18nConfig> = Omit<I18n, \"pathPrefix\"> & {\n pathPrefix: string;\n};\n\n/** The incoming request and its locale. */\ntype ShopifyRequestContextInputBase<I18n extends I18nConfig = I18nConfig> = {\n /**\n * The incoming request from your framework. When the framework exposes no request, such as during prerendering, pass `{ headers: new Headers() }`.\n */\n request: StorefrontRequest;\n /** The request's language, country, and optional localized path prefix. */\n i18n: I18n;\n};\n\ntype ShopifyRequestContextInput<I18n extends I18nConfig = I18nConfig> =\n ShopifyRequestContextInputBase<I18n> & { buyerIp?: never };\n\ntype ShopifyRequestContextWithBuyerIpInput<I18n extends I18nConfig = I18nConfig> =\n ShopifyRequestContextInputBase<I18n> & { buyerIp: string };\n\n/** The fields and methods on every request context. */\ntype ShopifyRequestContextBase = {\n // -- Private fields --\n /**\n * A type-only marker. Create request contexts only with `createShopifyRequestContext`.\n * @internal\n */\n readonly __hydrogenShopifyRequestContextBrand: never;\n /**\n * The incoming request's `Cookie` header. Hydrogen forwards the header to the Storefront API.\n * @internal\n */\n cookie?: string;\n /**\n * The customer's trusted IP address. Private Storefront API clients require it.\n * @internal\n */\n readonly buyerIp?: string;\n /**\n * The ID that groups this request's Shopify API calls. Hydrogen reads the ID from the request headers or generates one.\n * @internal\n */\n requestGroupId: string;\n /**\n * The incoming request's abort signal. Hydrogen's API clients stop their requests when the signal aborts.\n * @internal\n */\n signal?: AbortSignal;\n /**\n * The incoming request's URL.\n * @internal\n */\n url?: string;\n /**\n * The origin of the incoming request's URL. Hydrogen forwards the origin to the Storefront API.\n * @internal\n */\n storefrontOrigin?: string;\n /**\n * Adds the Hydrogen SDK headers and the customer's cookie, privacy, and origin headers to a\n * Storefront API request. The Storefront API client and the API proxies call the method for you.\n * @internal\n */\n applyStorefrontRequestHeaders(headers: Headers): void;\n /**\n * Saves the cookies from the first Storefront API response that sets cookies. The Storefront API\n * client calls the method for you, and `applyResponseHeaders()` can add the cookies to your response.\n * @internal\n */\n captureSubrequestHeaders(headers: Headers): void;\n /**\n * Saves the cookies from a proxied Storefront API response and removes the `set-cookie` header\n * from that response. The API proxies in `handleShopifyRoutes` call the method for you.\n * @internal\n */\n consumeStorefrontResponseHeaders(headers: Headers): void;\n /**\n * Marks the final response as private and uncacheable because the response holds customer data.\n * The Customer Account API client and the customer session call the method for you.\n * @internal\n */\n markResponseAsPersonalized(reason: string): void;\n /**\n * Lets the final response set Shopify cookies before the customer has a Shopify session, and\n * marks the response as private and uncacheable. The UCP MCP proxy calls the method for you.\n * @internal\n */\n markResponseAsSessionEstablishing(reason: string): void;\n\n // -- Public fields --\n /** The request's language, country, and path prefix. The path prefix starts with `/` and has no trailing slash, or is an empty string when you don't set one. */\n i18n: NormalizedI18nConfig;\n /** Returns the incoming request's headers with Shopify's request headers added. Pass the headers when you forward the request to a proxy or another origin. */\n getForwardedRequestHeaders(): Headers;\n /**\n * Adds the headers that a Hydrogen storefront needs to the final response. Call the method on each response that your app builds. Responses from `handleShopifyRoutes` and `handleShopifyRedirects` already carry these headers.\n *\n * Append your committed session headers before you call the method. The method makes any response that sets a cookie private and uncacheable. The method also sets the `powered-by` header, makes responses that hold customer data private and uncacheable, and adds eligible Shopify cookies only to non-document responses for methods other than `GET` and `HEAD`.\n */\n applyResponseHeaders(headers: Headers): void;\n};\n\n/** The context for one request. Pass the context to Hydrogen's API clients and route handlers for that request. */\nexport type ShopifyRequestContext<I18n extends I18nConfig = I18nConfig> =\n ShopifyRequestContextBase & {\n i18n: NormalizedI18nConfig<I18n>;\n };\n\n/** A request context that includes the customer's IP address. Private Storefront API clients require this context. */\nexport type ShopifyRequestContextWithBuyerIp<I18n extends I18nConfig = I18nConfig> =\n ShopifyRequestContext<I18n> & { readonly buyerIp: string };\n\n/** The values that the request context reads from the incoming request. */\ntype Context<I18n extends I18nConfig = I18nConfig> = {\n /** The incoming request's `Cookie` header. */\n cookie?: string;\n /** The incoming request's `Sec-GPC` header value, which carries the customer's Global Privacy Control signal. */\n globalPrivacyControl?: string;\n /** The customer's trusted IP address. */\n buyerIp?: string;\n /** The ID that groups this request's Shopify API calls. Hydrogen reads the ID from the request headers or generates one. */\n requestGroupId: string;\n /** The incoming request's abort signal. */\n signal?: AbortSignal;\n /** The incoming request's URL. */\n url?: string;\n /** The origin of the incoming request's URL. */\n storefrontOrigin?: string;\n /** The request's language, country, and path prefix. */\n i18n: NormalizedI18nConfig<I18n>;\n /** Whether the incoming request asks for an HTML document. */\n documentRequest?: boolean;\n};\n\n/**\n * Creates the context for one incoming request. Pass the same context to the Storefront API\n * client, the Customer Account API client, and `handleShopifyRoutes`. The context forwards the\n * customer's cookies, locale, and privacy signal to Shopify, and adds Shopify's cookies and cache\n * headers to your response.\n *\n * Call `applyResponseHeaders()` on each response that your app builds. Pass `buyerIp` with the customer's trusted IP address when you use a private Storefront API client. The function throws when `i18n` lacks a country or language, and when `buyerIp` is an empty string.\n *\n * @publicDocs\n */\nexport function createShopifyRequestContext<const I18n extends I18nConfig>(\n input: ShopifyRequestContextWithBuyerIpInput<I18n>,\n): ShopifyRequestContextWithBuyerIp<I18n>;\nexport function createShopifyRequestContext<const I18n extends I18nConfig>(\n input: ShopifyRequestContextInput<I18n>,\n): ShopifyRequestContext<I18n>;\n/**\n * @param input The incoming request, its locale, and the customer's IP address for private Storefront API clients.\n * @returns The context to pass to Hydrogen's API clients and route handlers for this request.\n */\nexport function createShopifyRequestContext<const I18n extends I18nConfig>(\n input: ShopifyRequestContextInputBase<I18n> & { buyerIp?: string },\n): ShopifyRequestContext<I18n> {\n const { request } = input;\n\n if (!input.i18n?.country || !input.i18n?.language) {\n throw new Error(\"i18n with country and language is required for Shopify request contexts.\");\n }\n\n if (input.buyerIp !== undefined && !input.buyerIp) {\n throw new Error(\"buyerIp must be non-empty when provided\");\n }\n\n const requestMethod = request.method?.toUpperCase();\n const i18n = normalizeI18n(input.i18n);\n const cookieHeader = request.headers.get(\"cookie\") || undefined;\n const inboundCookies = parseCookieHeader(cookieHeader);\n const hasEssentialCookie = inboundCookies.has(SHOPIFY_ESSENTIAL_COOKIE);\n const isConsentManagementRequest = request.headers.get(CONSENT_MANAGEMENT_HEADER) === \"1\";\n const url = request.url ?? request.headers.get(STOREFRONT_URL_HEADER) ?? undefined;\n const storefrontOrigin = getUrlOrigin(url);\n const context = {\n ...(cookieHeader && { cookie: cookieHeader }),\n globalPrivacyControl: request.headers.get(SEC_GPC_HEADER) ?? undefined,\n i18n,\n ...(url && { url }),\n ...(storefrontOrigin && { storefrontOrigin }),\n ...(input.buyerIp && { buyerIp: input.buyerIp }),\n requestGroupId:\n request.headers.get(REQUEST_GROUP_ID_HEADER) ??\n request.headers.get(\"x-request-id\") ??\n request.headers.get(\"request-id\") ??\n crypto.randomUUID(),\n ...(isDocumentRequest(request) && { documentRequest: true }),\n ...(request.signal && { signal: request.signal }),\n } as Context<I18n>;\n\n let capturedCookies: string[] | undefined;\n let personalizedResponseReason: string | undefined;\n let sessionEstablishingReason: string | undefined;\n\n const captureSubrequestHeaders = (headers: Headers): void => {\n // Capture the first fresh response cookies to increase the\n // chance of returning them from the main server response. The main response\n // needs headers set at send time, while the body can stream later, so this\n // may not be used if subrequests finish after the main response is sent.\n const cookies = headers.getSetCookie();\n if (cookies.length > 0) capturedCookies ??= cookies;\n };\n\n return {\n ...context,\n getForwardedRequestHeaders() {\n const headers = new Headers(request.headers);\n applyStorefrontRequestHeaders(context, headers);\n if (context.url) headers.set(STOREFRONT_URL_HEADER, context.url);\n return headers;\n },\n applyStorefrontRequestHeaders(headers) {\n applyStorefrontRequestHeaders(context, headers);\n },\n captureSubrequestHeaders,\n consumeStorefrontResponseHeaders(headers) {\n captureSubrequestHeaders(headers);\n headers.delete(\"set-cookie\");\n },\n markResponseAsPersonalized(reason) {\n personalizedResponseReason ??= reason;\n },\n markResponseAsSessionEstablishing(reason) {\n sessionEstablishingReason ??= reason;\n },\n applyResponseHeaders(headers) {\n headers.set(\"powered-by\", \"Shopify, Hydrogen\");\n\n // Documents may be shared or streamed, so they must not carry buyer-specific state.\n const isDocumentResponse =\n context.documentRequest || (headers.get(\"content-type\")?.startsWith(\"text/html\") ?? false);\n\n // Keep GET and HEAD responses cacheable, and fail closed when the request method is unknown.\n // Cold sessions may only be established by the explicitly marked consent request.\n const mayReturnShopifyState =\n requestMethod !== undefined &&\n requestMethod !== \"GET\" &&\n requestMethod !== \"HEAD\" &&\n !isDocumentResponse &&\n (hasEssentialCookie ||\n isConsentManagementRequest ||\n sessionEstablishingReason !== undefined);\n\n // Replay cookies captured from fresh SFAPI and proxy responses when allowed.\n if (capturedCookies && mayReturnShopifyState) {\n const existingSetCookies = new Set(headers.getSetCookie());\n for (const value of capturedCookies) {\n if (existingSetCookies.has(value)) continue;\n headers.append(\"set-cookie\", value);\n existingSetCookies.add(value);\n }\n }\n\n // Consent/session responses can contain private state in the body without setting cookies.\n if (\n personalizedResponseReason ||\n isConsentManagementRequest ||\n sessionEstablishingReason !== undefined ||\n headers.has(\"set-cookie\")\n ) {\n applyPrivateResponseCacheHeaders(headers);\n }\n },\n } as ShopifyRequestContext<I18n>;\n}\n\nfunction normalizeI18n<I18n extends I18nConfig>(i18n: I18n): NormalizedI18nConfig<I18n> {\n return {\n ...i18n,\n pathPrefix: normalizePathPrefix(i18n.pathPrefix),\n } as NormalizedI18nConfig<I18n>;\n}\n\nfunction applyStorefrontRequestHeaders(context: Context, headers: Headers): void {\n headers.set(SDK_VARIANT_HEADER, \"hydrogen\");\n headers.set(SDK_VARIANT_SOURCE_HEADER, \"kit\");\n headers.set(SDK_VERSION_HEADER, STOREFRONT_API_VERSION);\n headers.set(HYDROGEN_VERSION_HEADER, __HYDROGEN_VERSION__);\n headers.set(REQUEST_GROUP_ID_HEADER, context.requestGroupId);\n\n if (context.cookie) headers.set(\"cookie\", context.cookie);\n else headers.delete(\"cookie\");\n if (context.globalPrivacyControl !== undefined) {\n headers.set(SEC_GPC_HEADER, context.globalPrivacyControl);\n } else headers.delete(SEC_GPC_HEADER);\n if (context.storefrontOrigin) {\n headers.set(SHOPIFY_STOREFRONT_ORIGIN_HEADER, context.storefrontOrigin);\n } else headers.delete(SHOPIFY_STOREFRONT_ORIGIN_HEADER);\n}\n\nfunction getUrlOrigin(url: string | undefined): string | undefined {\n if (!url) return undefined;\n try {\n return new URL(url).origin;\n } catch {\n return undefined;\n }\n}\n\nfunction isDocumentRequest(request: StorefrontRequest): boolean {\n if (request.method && request.method !== \"GET\" && request.method !== \"HEAD\") return false;\n\n const destination = request.headers.get(\"sec-fetch-dest\");\n if (destination === \"document\") return true;\n\n return request.headers.get(\"accept\")?.includes(\"text/html\") ?? false;\n}\n\nfunction parseCookieHeader(cookieHeader: string | undefined): Map<string, string> {\n const cookies = new Map<string, string>();\n if (!cookieHeader) return cookies;\n\n for (const cookie of cookieHeader.split(\";\")) {\n const separator = cookie.indexOf(\"=\");\n if (separator < 0) continue;\n\n const name = cookie.slice(0, separator).trim();\n if (!cookies.has(name)) cookies.set(name, cookie.slice(separator + 1));\n }\n\n return cookies;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"request-context.mjs","names":[],"sources":["../../src/core/request-context.ts"],"sourcesContent":["import type {\n CountryCode as CustomerAccountCountryCode,\n LanguageCode as CustomerAccountLanguageCode,\n} from \"../graphql/generated/customer-account-api-types\";\nimport type {\n CountryCode as StorefrontCountryCode,\n LanguageCode as StorefrontLanguageCode,\n} from \"../graphql/generated/storefront-api-types\";\nimport { STOREFRONT_API_VERSION } from \"./constants\";\nimport {\n applyPrivateResponseCacheHeaders,\n CONSENT_MANAGEMENT_HEADER,\n HYDROGEN_VERSION_HEADER,\n REQUEST_GROUP_ID_HEADER,\n SDK_VARIANT_HEADER,\n SDK_VARIANT_SOURCE_HEADER,\n SDK_VERSION_HEADER,\n SEC_GPC_HEADER,\n SHOPIFY_STOREFRONT_ORIGIN_HEADER,\n STOREFRONT_URL_HEADER,\n} from \"./headers\";\nimport { normalizePathPrefix } from \"./standard-routes/path\";\n\nconst SHOPIFY_ESSENTIAL_COOKIE = \"_shopify_essential\";\n\n/** The incoming request's headers, plus its method, abort signal, and URL when the framework provides them. */\ntype StorefrontRequest = Pick<Request, \"headers\"> &\n Partial<Pick<Request, \"method\" | \"signal\" | \"url\">>;\n\n/** A language code that both the Storefront API and the Customer Account API accept. */\nexport type ShopifyLanguageCode = Extract<StorefrontLanguageCode, CustomerAccountLanguageCode>;\n/** A country code that both the Storefront API and the Customer Account API accept. */\nexport type ShopifyCountryCode = Extract<StorefrontCountryCode, CustomerAccountCountryCode>;\n\n/** The language, country, and optional localized path prefix for a request. */\nexport type I18nConfig = {\n /** The request's language. */\n language: ShopifyLanguageCode;\n /** The request's country. */\n country: ShopifyCountryCode;\n /**\n * The URL prefix for localized routes, such as `/es-es`. Hydrogen adds the prefix to standard route redirects and product variant URLs.\n */\n pathPrefix?: string;\n};\n\n/** The request's locale, with a path prefix that starts with `/` or is an empty string. */\ntype NormalizedI18nConfig<I18n extends I18nConfig = I18nConfig> = Omit<I18n, \"pathPrefix\"> & {\n pathPrefix: string;\n};\n\n/** The incoming request and its locale. */\ntype ShopifyRequestContextInputBase<I18n extends I18nConfig = I18nConfig> = {\n /**\n * The incoming request from your framework. When the framework exposes no request, such as during prerendering, pass `{ headers: new Headers() }`.\n */\n request: StorefrontRequest;\n /** The request's language, country, and optional localized path prefix. */\n i18n: I18n;\n};\n\ntype ShopifyRequestContextInput<I18n extends I18nConfig = I18nConfig> =\n ShopifyRequestContextInputBase<I18n> & { buyerIp?: never };\n\ntype ShopifyRequestContextWithBuyerIpInput<I18n extends I18nConfig = I18nConfig> =\n ShopifyRequestContextInputBase<I18n> & { buyerIp: string };\n\n/** The fields and methods on every request context. */\ntype ShopifyRequestContextBase = {\n // -- Private fields --\n /**\n * A type-only marker. Create request contexts only with `createShopifyRequestContext`.\n * @internal\n */\n readonly __hydrogenShopifyRequestContextBrand: never;\n /**\n * The incoming request's `Cookie` header. Hydrogen forwards the header to the Storefront API.\n * @internal\n */\n cookie?: string;\n /**\n * The customer's trusted IP address. Private Storefront API clients require it.\n * @internal\n */\n readonly buyerIp?: string;\n /**\n * The ID that groups this request's Shopify API calls. Hydrogen reads the ID from the request headers or generates one.\n * @internal\n */\n requestGroupId: string;\n /**\n * The incoming request's abort signal. Hydrogen's API clients stop their requests when the signal aborts.\n * @internal\n */\n signal?: AbortSignal;\n /**\n * The incoming request's URL.\n * @internal\n */\n url?: string;\n /**\n * The origin of the incoming request's URL. Hydrogen forwards the origin to the Storefront API.\n * @internal\n */\n storefrontOrigin?: string;\n /**\n * Adds the Hydrogen SDK headers and the customer's cookie, privacy, and origin headers to a\n * Storefront API request. The Storefront API client and the API proxies call the method for you.\n * @internal\n */\n applyStorefrontRequestHeaders(headers: Headers): void;\n /**\n * Saves the cookies from the first Storefront API response that sets cookies. The Storefront API\n * client calls the method for you, and `applyResponseHeaders()` can add the cookies to your response.\n * @internal\n */\n captureSubrequestHeaders(headers: Headers): void;\n /**\n * Saves the cookies from a proxied Storefront API response and removes the `set-cookie` header\n * from that response. The API proxies in `handleShopifyRoutes` call the method for you.\n * @internal\n */\n consumeStorefrontResponseHeaders(headers: Headers): void;\n /**\n * Marks the final response as private and uncacheable because the response holds customer data.\n * The Customer Account API client and the customer session call the method for you.\n * @internal\n */\n markResponseAsPersonalized(reason: string): void;\n /**\n * Lets the final response set Shopify cookies before the customer has a Shopify session, and\n * marks the response as private and uncacheable. The UCP MCP proxy calls the method for you.\n * @internal\n */\n markResponseAsSessionEstablishing(reason: string): void;\n\n // -- Public fields --\n /** The request's language, country, and path prefix. The path prefix starts with `/` and has no trailing slash, or is an empty string when you don't set one. */\n i18n: NormalizedI18nConfig;\n /** Returns the incoming request's headers with Shopify's request headers added. Pass the headers when you forward the request to a proxy or another origin. */\n getForwardedRequestHeaders(): Headers;\n /**\n * Adds the headers that a Hydrogen storefront needs to the final response. Call the method on each response that your app builds. Responses from `handleShopifyRoutes` and `handleShopifyRedirects` already carry these headers.\n *\n * Append your committed session headers before you call the method. The method makes any response that sets a cookie private and uncacheable. The method also sets the `powered-by` header, makes responses that hold customer data private and uncacheable, and adds eligible Shopify cookies only to non-document responses for methods other than `GET` and `HEAD`.\n */\n applyResponseHeaders(headers: Headers): void;\n};\n\n/** The context for one request. Pass the context to Hydrogen's API clients and route handlers for that request. */\nexport type ShopifyRequestContext<I18n extends I18nConfig = I18nConfig> =\n ShopifyRequestContextBase & {\n i18n: NormalizedI18nConfig<I18n>;\n };\n\n/** A request context that includes the customer's IP address. Private Storefront API clients require this context. */\nexport type ShopifyRequestContextWithBuyerIp<I18n extends I18nConfig = I18nConfig> =\n ShopifyRequestContext<I18n> & { readonly buyerIp: string };\n\n/** The values that the request context reads from the incoming request. */\ntype Context<I18n extends I18nConfig = I18nConfig> = {\n /** The incoming request's `Cookie` header. */\n cookie?: string;\n /** The incoming request's `Sec-GPC` header value, which carries the customer's Global Privacy Control signal. */\n globalPrivacyControl?: string;\n /** The customer's trusted IP address. */\n buyerIp?: string;\n /** The ID that groups this request's Shopify API calls. Hydrogen reads the ID from the request headers or generates one. */\n requestGroupId: string;\n /** The incoming request's abort signal. */\n signal?: AbortSignal;\n /** The incoming request's URL. */\n url?: string;\n /** The origin of the incoming request's URL. */\n storefrontOrigin?: string;\n /** The request's language, country, and path prefix. */\n i18n: NormalizedI18nConfig<I18n>;\n /** Whether the incoming request asks for an HTML document. */\n documentRequest?: boolean;\n};\n\n/**\n * Creates the context for one incoming request. Pass the same context to the Storefront API\n * client, the Customer Account API client, and `handleShopifyRoutes`. The context forwards the\n * customer's cookies, locale, and privacy signal to Shopify, and adds Shopify's cookies and cache\n * headers to your response.\n *\n * Call `applyResponseHeaders()` on each response that your app builds. Pass `buyerIp` with the customer's trusted IP address when you use a private Storefront API client. The function throws when `i18n` lacks a country or language, and when `buyerIp` is an empty string.\n *\n * @publicDocs\n */\nexport function createShopifyRequestContext<const I18n extends I18nConfig>(\n input: ShopifyRequestContextWithBuyerIpInput<I18n>,\n): ShopifyRequestContextWithBuyerIp<I18n>;\nexport function createShopifyRequestContext<const I18n extends I18nConfig>(\n input: ShopifyRequestContextInput<I18n>,\n): ShopifyRequestContext<I18n>;\n/**\n * @param input The incoming request, its locale, and the customer's IP address for private Storefront API clients.\n * @returns The context to pass to Hydrogen's API clients and route handlers for this request.\n */\nexport function createShopifyRequestContext<const I18n extends I18nConfig>(\n input: ShopifyRequestContextInputBase<I18n> & { buyerIp?: string },\n): ShopifyRequestContext<I18n> {\n const { request } = input;\n\n if (!input.i18n?.country || !input.i18n?.language) {\n throw new Error(\"i18n with country and language is required for Shopify request contexts.\");\n }\n\n if (input.buyerIp !== undefined && !input.buyerIp) {\n throw new Error(\"buyerIp must be non-empty when provided\");\n }\n\n const requestMethod = request.method?.toUpperCase();\n const i18n = normalizeI18n(input.i18n);\n const cookieHeader = request.headers.get(\"cookie\") || undefined;\n const inboundCookies = parseCookieHeader(cookieHeader);\n const hasEssentialCookie = inboundCookies.has(SHOPIFY_ESSENTIAL_COOKIE);\n const isConsentManagementRequest = request.headers.get(CONSENT_MANAGEMENT_HEADER) === \"1\";\n const url = request.url ?? request.headers.get(STOREFRONT_URL_HEADER) ?? undefined;\n const storefrontOrigin = getUrlOrigin(url);\n const context = {\n ...(cookieHeader && { cookie: cookieHeader }),\n globalPrivacyControl: request.headers.get(SEC_GPC_HEADER) ?? undefined,\n i18n,\n ...(url && { url }),\n ...(storefrontOrigin && { storefrontOrigin }),\n ...(input.buyerIp && { buyerIp: input.buyerIp }),\n requestGroupId:\n request.headers.get(REQUEST_GROUP_ID_HEADER) ??\n request.headers.get(\"x-request-id\") ??\n request.headers.get(\"request-id\") ??\n crypto.randomUUID(),\n ...(isDocumentRequest(request) && { documentRequest: true }),\n ...(request.signal && { signal: request.signal }),\n } as Context<I18n>;\n\n let capturedCookies: string[] | undefined;\n let personalizedResponseReason: string | undefined;\n let sessionEstablishingReason: string | undefined;\n\n const captureSubrequestHeaders = (headers: Headers): void => {\n // Capture the first fresh response cookies to increase the\n // chance of returning them from the main server response. The main response\n // needs headers set at send time, while the body can stream later, so this\n // may not be used if subrequests finish after the main response is sent.\n const cookies = headers.getSetCookie();\n if (cookies.length > 0) capturedCookies ??= cookies;\n };\n\n return {\n ...context,\n getForwardedRequestHeaders() {\n const headers = new Headers(request.headers);\n applyStorefrontRequestHeaders(context, headers);\n if (context.url) headers.set(STOREFRONT_URL_HEADER, context.url);\n return headers;\n },\n applyStorefrontRequestHeaders(headers) {\n applyStorefrontRequestHeaders(context, headers);\n },\n captureSubrequestHeaders,\n consumeStorefrontResponseHeaders(headers) {\n captureSubrequestHeaders(headers);\n headers.delete(\"set-cookie\");\n },\n markResponseAsPersonalized(reason) {\n personalizedResponseReason ??= reason;\n },\n markResponseAsSessionEstablishing(reason) {\n sessionEstablishingReason ??= reason;\n },\n applyResponseHeaders(headers) {\n headers.set(\"powered-by\", \"Shopify, Hydrogen\");\n\n // Documents may be shared or streamed, so they must not carry buyer-specific state.\n const isDocumentResponse =\n context.documentRequest || (headers.get(\"content-type\")?.startsWith(\"text/html\") ?? false);\n\n // Keep GET and HEAD responses cacheable, and fail closed when the request method is unknown.\n // Cold sessions may only be established by the explicitly marked consent request.\n const mayReturnShopifyState =\n requestMethod !== undefined &&\n requestMethod !== \"GET\" &&\n requestMethod !== \"HEAD\" &&\n !isDocumentResponse &&\n (hasEssentialCookie ||\n isConsentManagementRequest ||\n sessionEstablishingReason !== undefined);\n\n // Replay cookies captured from fresh SFAPI and proxy responses when allowed.\n if (capturedCookies && mayReturnShopifyState) {\n const existingSetCookies = new Set(headers.getSetCookie());\n for (const value of capturedCookies) {\n if (existingSetCookies.has(value)) continue;\n headers.append(\"set-cookie\", value);\n existingSetCookies.add(value);\n }\n }\n\n // Consent/session responses can contain private state in the body without setting cookies.\n if (\n personalizedResponseReason ||\n isConsentManagementRequest ||\n sessionEstablishingReason !== undefined ||\n headers.has(\"set-cookie\")\n ) {\n applyPrivateResponseCacheHeaders(headers);\n }\n },\n } as ShopifyRequestContext<I18n>;\n}\n\nfunction normalizeI18n<I18n extends I18nConfig>(i18n: I18n): NormalizedI18nConfig<I18n> {\n return {\n ...i18n,\n pathPrefix: normalizePathPrefix(i18n.pathPrefix),\n } as NormalizedI18nConfig<I18n>;\n}\n\nfunction applyStorefrontRequestHeaders(context: Context, headers: Headers): void {\n headers.set(SDK_VARIANT_HEADER, \"hydrogen\");\n headers.set(SDK_VARIANT_SOURCE_HEADER, \"kit\");\n headers.set(SDK_VERSION_HEADER, STOREFRONT_API_VERSION);\n headers.set(HYDROGEN_VERSION_HEADER, __HYDROGEN_VERSION__);\n headers.set(REQUEST_GROUP_ID_HEADER, context.requestGroupId);\n\n if (context.cookie) headers.set(\"cookie\", context.cookie);\n else headers.delete(\"cookie\");\n if (context.globalPrivacyControl !== undefined) {\n headers.set(SEC_GPC_HEADER, context.globalPrivacyControl);\n } else headers.delete(SEC_GPC_HEADER);\n if (context.storefrontOrigin) {\n headers.set(SHOPIFY_STOREFRONT_ORIGIN_HEADER, context.storefrontOrigin);\n } else headers.delete(SHOPIFY_STOREFRONT_ORIGIN_HEADER);\n}\n\nfunction getUrlOrigin(url: string | undefined): string | undefined {\n if (!url) return undefined;\n try {\n return new URL(url).origin;\n } catch {\n return undefined;\n }\n}\n\nfunction isDocumentRequest(request: StorefrontRequest): boolean {\n if (request.method && request.method !== \"GET\" && request.method !== \"HEAD\") return false;\n\n const destination = request.headers.get(\"sec-fetch-dest\");\n if (destination === \"document\") return true;\n\n return request.headers.get(\"accept\")?.includes(\"text/html\") ?? false;\n}\n\nfunction parseCookieHeader(cookieHeader: string | undefined): Map<string, string> {\n const cookies = new Map<string, string>();\n if (!cookieHeader) return cookies;\n\n for (const cookie of cookieHeader.split(\";\")) {\n const separator = cookie.indexOf(\"=\");\n if (separator < 0) continue;\n\n const name = cookie.slice(0, separator).trim();\n if (!cookies.has(name)) cookies.set(name, cookie.slice(separator + 1));\n }\n\n return cookies;\n}\n"],"mappings":";;;;;AAuBA,MAAM,2BAA2B;;;;;AAkLjC,SAAgB,4BACd,OAC6B;CAC7B,MAAM,EAAE,YAAY;AAEpB,KAAI,CAAC,MAAM,MAAM,WAAW,CAAC,MAAM,MAAM,SACvC,OAAM,IAAI,MAAM,2EAA2E;AAG7F,KAAI,MAAM,YAAY,UAAa,CAAC,MAAM,QACxC,OAAM,IAAI,MAAM,0CAA0C;CAG5D,MAAM,gBAAgB,QAAQ,QAAQ,aAAa;CACnD,MAAM,OAAO,cAAc,MAAM,KAAK;CACtC,MAAM,eAAe,QAAQ,QAAQ,IAAI,SAAS,IAAI;CAEtD,MAAM,qBADiB,kBAAkB,aACA,CAAC,IAAI,yBAAyB;CACvE,MAAM,6BAA6B,QAAQ,QAAQ,IAAI,0BAA0B,KAAK;CACtF,MAAM,MAAM,QAAQ,OAAO,QAAQ,QAAQ,uBAA0B,IAAI;CACzE,MAAM,mBAAmB,aAAa,IAAI;CAC1C,MAAM,UAAU;EACd,GAAI,gBAAgB,EAAE,QAAQ,cAAc;EAC5C,sBAAsB,QAAQ,QAAQ,cAAmB,IAAI;EAC7D;EACA,GAAI,OAAO,EAAE,KAAK;EAClB,GAAI,oBAAoB,EAAE,kBAAkB;EAC5C,GAAI,MAAM,WAAW,EAAE,SAAS,MAAM,SAAS;EAC/C,gBACE,QAAQ,QAAQ,yCAA4B,IAC5C,QAAQ,QAAQ,IAAI,eAAe,IACnC,QAAQ,QAAQ,IAAI,aAAa,IACjC,OAAO,YAAY;EACrB,GAAI,kBAAkB,QAAQ,IAAI,EAAE,iBAAiB,MAAM;EAC3D,GAAI,QAAQ,UAAU,EAAE,QAAQ,QAAQ,QAAQ;EACjD;CAED,IAAI;CACJ,IAAI;CACJ,IAAI;CAEJ,MAAM,4BAA4B,YAA2B;EAK3D,MAAM,UAAU,QAAQ,cAAc;AACtC,MAAI,QAAQ,SAAS,EAAG,qBAAoB;;AAG9C,QAAO;EACL,GAAG;EACH,6BAA6B;GAC3B,MAAM,UAAU,IAAI,QAAQ,QAAQ,QAAQ;AAC5C,iCAA8B,SAAS,QAAQ;AAC/C,OAAI,QAAQ,IAAK,SAAQ,IAAI,uBAAuB,QAAQ,IAAI;AAChE,UAAO;;EAET,8BAA8B,SAAS;AACrC,iCAA8B,SAAS,QAAQ;;EAEjD;EACA,iCAAiC,SAAS;AACxC,4BAAyB,QAAQ;AACjC,WAAQ,OAAO,aAAa;;EAE9B,2BAA2B,QAAQ;AACjC,kCAA+B;;EAEjC,kCAAkC,QAAQ;AACxC,iCAA8B;;EAEhC,qBAAqB,SAAS;AAC5B,WAAQ,IAAI,cAAc,oBAAoB;GAG9C,MAAM,qBACJ,QAAQ,oBAAoB,QAAQ,IAAI,eAAe,EAAE,WAAW,YAAY,IAAI;AActF,OAAI,mBATF,kBAAkB,UAClB,kBAAkB,SAClB,kBAAkB,UAClB,CAAC,uBACA,sBACC,8BACA,8BAA8B,SAGY;IAC5C,MAAM,qBAAqB,IAAI,IAAI,QAAQ,cAAc,CAAC;AAC1D,SAAK,MAAM,SAAS,iBAAiB;AACnC,SAAI,mBAAmB,IAAI,MAAM,CAAE;AACnC,aAAQ,OAAO,cAAc,MAAM;AACnC,wBAAmB,IAAI,MAAM;;;AAKjC,OACE,8BACA,8BACA,8BAA8B,UAC9B,QAAQ,IAAI,aAAa,CAEzB,kCAAiC,QAAQ;;EAG9C;;AAGH,SAAS,cAAuC,MAAwC;AACtF,QAAO;EACL,GAAG;EACH,YAAY,oBAAoB,KAAK,WAAW;EACjD;;AAGH,SAAS,8BAA8B,SAAkB,SAAwB;AAC/E,SAAQ,IAAI,oBAAoB,WAAW;AAC3C,SAAQ,IAAI,2BAA2B,MAAM;AAC7C,SAAQ,IAAI,oBAAoB,uBAAuB;AACvD,SAAQ,IAAI,qCAA8C;AAC1D,SAAQ,IAAI,yBAAyB,QAAQ,eAAe;AAE5D,KAAI,QAAQ,OAAQ,SAAQ,IAAI,UAAU,QAAQ,OAAO;KACpD,SAAQ,OAAO,SAAS;AAC7B,KAAI,QAAQ,yBAAyB,OACnC,SAAQ,IAAI,gBAAgB,QAAQ,qBAAqB;KACpD,SAAQ,OAAO,eAAe;AACrC,KAAI,QAAQ,iBACV,SAAQ,IAAI,kCAAkC,QAAQ,iBAAiB;KAClE,SAAQ,OAAO,iCAAiC;;AAGzD,SAAS,aAAa,KAA6C;AACjE,KAAI,CAAC,IAAK,QAAO;AACjB,KAAI;AACF,SAAO,IAAI,IAAI,IAAI,CAAC;SACd;AACN;;;AAIJ,SAAS,kBAAkB,SAAqC;AAC9D,KAAI,QAAQ,UAAU,QAAQ,WAAW,SAAS,QAAQ,WAAW,OAAQ,QAAO;AAGpF,KADoB,QAAQ,QAAQ,IAAI,iBACzB,KAAK,WAAY,QAAO;AAEvC,QAAO,QAAQ,QAAQ,IAAI,SAAS,EAAE,SAAS,YAAY,IAAI;;AAGjE,SAAS,kBAAkB,cAAuD;CAChF,MAAM,0BAAU,IAAI,KAAqB;AACzC,KAAI,CAAC,aAAc,QAAO;AAE1B,MAAK,MAAM,UAAU,aAAa,MAAM,IAAI,EAAE;EAC5C,MAAM,YAAY,OAAO,QAAQ,IAAI;AACrC,MAAI,YAAY,EAAG;EAEnB,MAAM,OAAO,OAAO,MAAM,GAAG,UAAU,CAAC,MAAM;AAC9C,MAAI,CAAC,QAAQ,IAAI,KAAK,CAAE,SAAQ,IAAI,MAAM,OAAO,MAAM,YAAY,EAAE,CAAC;;AAGxE,QAAO"}
|
|
@@ -15,7 +15,7 @@ const LOCAL_ORIGIN_HOSTS = new Set([
|
|
|
15
15
|
"::1",
|
|
16
16
|
"[::1]"
|
|
17
17
|
]);
|
|
18
|
-
const USER_AGENT = `Hydrogen 2026.10.
|
|
18
|
+
const USER_AGENT = `Hydrogen 2026.10.1`;
|
|
19
19
|
const CUSTOMER_ACCOUNT_GRAPHQL_PERSONALIZATION_REASON = "customer-account-graphql";
|
|
20
20
|
/**
|
|
21
21
|
* Creates a client that sends Customer Account API requests from your server. Create the client with the current request's context.
|
|
@@ -44,7 +44,7 @@ const AUTHORIZATION_CODE_GRANT_TYPE = "authorization_code";
|
|
|
44
44
|
const REFRESH_TOKEN_GRANT_TYPE = "refresh_token";
|
|
45
45
|
const CUSTOMER_ACCOUNT_SCOPE = "openid email customer-account-api:full";
|
|
46
46
|
const CODE_CHALLENGE_METHOD = "S256";
|
|
47
|
-
const USER_AGENT = `Hydrogen 2026.10.
|
|
47
|
+
const USER_AGENT = `Hydrogen 2026.10.1`;
|
|
48
48
|
const EXPIRY_BUFFER_IN_SECONDS = 120;
|
|
49
49
|
const PENDING_LOGIN_TTL_IN_MINUTES = 10;
|
|
50
50
|
const SECONDS_PER_MINUTE = 60;
|
|
@@ -84,7 +84,7 @@ function applyStorefrontRequestHeaders(context, headers) {
|
|
|
84
84
|
headers.set(SDK_VARIANT_HEADER, "hydrogen");
|
|
85
85
|
headers.set(SDK_VARIANT_SOURCE_HEADER, "kit");
|
|
86
86
|
headers.set(SDK_VERSION_HEADER, STOREFRONT_API_VERSION);
|
|
87
|
-
headers.set(HYDROGEN_VERSION_HEADER, "2026.10.
|
|
87
|
+
headers.set(HYDROGEN_VERSION_HEADER, "2026.10.1");
|
|
88
88
|
headers.set(REQUEST_GROUP_ID_HEADER, context.requestGroupId);
|
|
89
89
|
if (context.cookie) headers.set("cookie", context.cookie);
|
|
90
90
|
else headers.delete("cookie");
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"request-context.mjs","names":[],"sources":["../../../src/core/request-context.ts"],"sourcesContent":["import type {\n CountryCode as CustomerAccountCountryCode,\n LanguageCode as CustomerAccountLanguageCode,\n} from \"../graphql/generated/customer-account-api-types\";\nimport type {\n CountryCode as StorefrontCountryCode,\n LanguageCode as StorefrontLanguageCode,\n} from \"../graphql/generated/storefront-api-types\";\nimport { STOREFRONT_API_VERSION } from \"./constants\";\nimport {\n applyPrivateResponseCacheHeaders,\n CONSENT_MANAGEMENT_HEADER,\n HYDROGEN_VERSION_HEADER,\n REQUEST_GROUP_ID_HEADER,\n SDK_VARIANT_HEADER,\n SDK_VARIANT_SOURCE_HEADER,\n SDK_VERSION_HEADER,\n SEC_GPC_HEADER,\n SHOPIFY_STOREFRONT_ORIGIN_HEADER,\n STOREFRONT_URL_HEADER,\n} from \"./headers\";\nimport { normalizePathPrefix } from \"./standard-routes/path\";\n\nconst SHOPIFY_ESSENTIAL_COOKIE = \"_shopify_essential\";\n\n/** The incoming request's headers, plus its method, abort signal, and URL when the framework provides them. */\ntype StorefrontRequest = Pick<Request, \"headers\"> &\n Partial<Pick<Request, \"method\" | \"signal\" | \"url\">>;\n\n/** A language code that both the Storefront API and the Customer Account API accept. */\nexport type ShopifyLanguageCode = Extract<StorefrontLanguageCode, CustomerAccountLanguageCode>;\n/** A country code that both the Storefront API and the Customer Account API accept. */\nexport type ShopifyCountryCode = Extract<StorefrontCountryCode, CustomerAccountCountryCode>;\n\n/** The language, country, and optional localized path prefix for a request. */\nexport type I18nConfig = {\n /** The request's language. */\n language: ShopifyLanguageCode;\n /** The request's country. */\n country: ShopifyCountryCode;\n /**\n * The URL prefix for localized routes, such as `/es-es`. Hydrogen adds the prefix to standard route redirects and product variant URLs.\n */\n pathPrefix?: string;\n};\n\n/** The request's locale, with a path prefix that starts with `/` or is an empty string. */\ntype NormalizedI18nConfig<I18n extends I18nConfig = I18nConfig> = Omit<I18n, \"pathPrefix\"> & {\n pathPrefix: string;\n};\n\n/** The incoming request and its locale. */\ntype ShopifyRequestContextInputBase<I18n extends I18nConfig = I18nConfig> = {\n /**\n * The incoming request from your framework. When the framework exposes no request, such as during prerendering, pass `{ headers: new Headers() }`.\n */\n request: StorefrontRequest;\n /** The request's language, country, and optional localized path prefix. */\n i18n: I18n;\n};\n\ntype ShopifyRequestContextInput<I18n extends I18nConfig = I18nConfig> =\n ShopifyRequestContextInputBase<I18n> & { buyerIp?: never };\n\ntype ShopifyRequestContextWithBuyerIpInput<I18n extends I18nConfig = I18nConfig> =\n ShopifyRequestContextInputBase<I18n> & { buyerIp: string };\n\n/** The fields and methods on every request context. */\ntype ShopifyRequestContextBase = {\n // -- Private fields --\n /**\n * A type-only marker. Create request contexts only with `createShopifyRequestContext`.\n * @internal\n */\n readonly __hydrogenShopifyRequestContextBrand: never;\n /**\n * The incoming request's `Cookie` header. Hydrogen forwards the header to the Storefront API.\n * @internal\n */\n cookie?: string;\n /**\n * The customer's trusted IP address. Private Storefront API clients require it.\n * @internal\n */\n readonly buyerIp?: string;\n /**\n * The ID that groups this request's Shopify API calls. Hydrogen reads the ID from the request headers or generates one.\n * @internal\n */\n requestGroupId: string;\n /**\n * The incoming request's abort signal. Hydrogen's API clients stop their requests when the signal aborts.\n * @internal\n */\n signal?: AbortSignal;\n /**\n * The incoming request's URL.\n * @internal\n */\n url?: string;\n /**\n * The origin of the incoming request's URL. Hydrogen forwards the origin to the Storefront API.\n * @internal\n */\n storefrontOrigin?: string;\n /**\n * Adds the Hydrogen SDK headers and the customer's cookie, privacy, and origin headers to a\n * Storefront API request. The Storefront API client and the API proxies call the method for you.\n * @internal\n */\n applyStorefrontRequestHeaders(headers: Headers): void;\n /**\n * Saves the cookies from the first Storefront API response that sets cookies. The Storefront API\n * client calls the method for you, and `applyResponseHeaders()` can add the cookies to your response.\n * @internal\n */\n captureSubrequestHeaders(headers: Headers): void;\n /**\n * Saves the cookies from a proxied Storefront API response and removes the `set-cookie` header\n * from that response. The API proxies in `handleShopifyRoutes` call the method for you.\n * @internal\n */\n consumeStorefrontResponseHeaders(headers: Headers): void;\n /**\n * Marks the final response as private and uncacheable because the response holds customer data.\n * The Customer Account API client and the customer session call the method for you.\n * @internal\n */\n markResponseAsPersonalized(reason: string): void;\n /**\n * Lets the final response set Shopify cookies before the customer has a Shopify session, and\n * marks the response as private and uncacheable. The UCP MCP proxy calls the method for you.\n * @internal\n */\n markResponseAsSessionEstablishing(reason: string): void;\n\n // -- Public fields --\n /** The request's language, country, and path prefix. The path prefix starts with `/` and has no trailing slash, or is an empty string when you don't set one. */\n i18n: NormalizedI18nConfig;\n /** Returns the incoming request's headers with Shopify's request headers added. Pass the headers when you forward the request to a proxy or another origin. */\n getForwardedRequestHeaders(): Headers;\n /**\n * Adds the headers that a Hydrogen storefront needs to the final response. Call the method on each response that your app builds. Responses from `handleShopifyRoutes` and `handleShopifyRedirects` already carry these headers.\n *\n * Append your committed session headers before you call the method. The method makes any response that sets a cookie private and uncacheable. The method also sets the `powered-by` header, makes responses that hold customer data private and uncacheable, and adds eligible Shopify cookies only to non-document responses for methods other than `GET` and `HEAD`.\n */\n applyResponseHeaders(headers: Headers): void;\n};\n\n/** The context for one request. Pass the context to Hydrogen's API clients and route handlers for that request. */\nexport type ShopifyRequestContext<I18n extends I18nConfig = I18nConfig> =\n ShopifyRequestContextBase & {\n i18n: NormalizedI18nConfig<I18n>;\n };\n\n/** A request context that includes the customer's IP address. Private Storefront API clients require this context. */\nexport type ShopifyRequestContextWithBuyerIp<I18n extends I18nConfig = I18nConfig> =\n ShopifyRequestContext<I18n> & { readonly buyerIp: string };\n\n/** The values that the request context reads from the incoming request. */\ntype Context<I18n extends I18nConfig = I18nConfig> = {\n /** The incoming request's `Cookie` header. */\n cookie?: string;\n /** The incoming request's `Sec-GPC` header value, which carries the customer's Global Privacy Control signal. */\n globalPrivacyControl?: string;\n /** The customer's trusted IP address. */\n buyerIp?: string;\n /** The ID that groups this request's Shopify API calls. Hydrogen reads the ID from the request headers or generates one. */\n requestGroupId: string;\n /** The incoming request's abort signal. */\n signal?: AbortSignal;\n /** The incoming request's URL. */\n url?: string;\n /** The origin of the incoming request's URL. */\n storefrontOrigin?: string;\n /** The request's language, country, and path prefix. */\n i18n: NormalizedI18nConfig<I18n>;\n /** Whether the incoming request asks for an HTML document. */\n documentRequest?: boolean;\n};\n\n/**\n * Creates the context for one incoming request. Pass the same context to the Storefront API\n * client, the Customer Account API client, and `handleShopifyRoutes`. The context forwards the\n * customer's cookies, locale, and privacy signal to Shopify, and adds Shopify's cookies and cache\n * headers to your response.\n *\n * Call `applyResponseHeaders()` on each response that your app builds. Pass `buyerIp` with the customer's trusted IP address when you use a private Storefront API client. The function throws when `i18n` lacks a country or language, and when `buyerIp` is an empty string.\n *\n * @publicDocs\n */\nexport function createShopifyRequestContext<const I18n extends I18nConfig>(\n input: ShopifyRequestContextWithBuyerIpInput<I18n>,\n): ShopifyRequestContextWithBuyerIp<I18n>;\nexport function createShopifyRequestContext<const I18n extends I18nConfig>(\n input: ShopifyRequestContextInput<I18n>,\n): ShopifyRequestContext<I18n>;\n/**\n * @param input The incoming request, its locale, and the customer's IP address for private Storefront API clients.\n * @returns The context to pass to Hydrogen's API clients and route handlers for this request.\n */\nexport function createShopifyRequestContext<const I18n extends I18nConfig>(\n input: ShopifyRequestContextInputBase<I18n> & { buyerIp?: string },\n): ShopifyRequestContext<I18n> {\n const { request } = input;\n\n if (!input.i18n?.country || !input.i18n?.language) {\n throw new Error(\"i18n with country and language is required for Shopify request contexts.\");\n }\n\n if (input.buyerIp !== undefined && !input.buyerIp) {\n throw new Error(\"buyerIp must be non-empty when provided\");\n }\n\n const requestMethod = request.method?.toUpperCase();\n const i18n = normalizeI18n(input.i18n);\n const cookieHeader = request.headers.get(\"cookie\") || undefined;\n const inboundCookies = parseCookieHeader(cookieHeader);\n const hasEssentialCookie = inboundCookies.has(SHOPIFY_ESSENTIAL_COOKIE);\n const isConsentManagementRequest = request.headers.get(CONSENT_MANAGEMENT_HEADER) === \"1\";\n const url = request.url ?? request.headers.get(STOREFRONT_URL_HEADER) ?? undefined;\n const storefrontOrigin = getUrlOrigin(url);\n const context = {\n ...(cookieHeader && { cookie: cookieHeader }),\n globalPrivacyControl: request.headers.get(SEC_GPC_HEADER) ?? undefined,\n i18n,\n ...(url && { url }),\n ...(storefrontOrigin && { storefrontOrigin }),\n ...(input.buyerIp && { buyerIp: input.buyerIp }),\n requestGroupId:\n request.headers.get(REQUEST_GROUP_ID_HEADER) ??\n request.headers.get(\"x-request-id\") ??\n request.headers.get(\"request-id\") ??\n crypto.randomUUID(),\n ...(isDocumentRequest(request) && { documentRequest: true }),\n ...(request.signal && { signal: request.signal }),\n } as Context<I18n>;\n\n let capturedCookies: string[] | undefined;\n let personalizedResponseReason: string | undefined;\n let sessionEstablishingReason: string | undefined;\n\n const captureSubrequestHeaders = (headers: Headers): void => {\n // Capture the first fresh response cookies to increase the\n // chance of returning them from the main server response. The main response\n // needs headers set at send time, while the body can stream later, so this\n // may not be used if subrequests finish after the main response is sent.\n const cookies = headers.getSetCookie();\n if (cookies.length > 0) capturedCookies ??= cookies;\n };\n\n return {\n ...context,\n getForwardedRequestHeaders() {\n const headers = new Headers(request.headers);\n applyStorefrontRequestHeaders(context, headers);\n if (context.url) headers.set(STOREFRONT_URL_HEADER, context.url);\n return headers;\n },\n applyStorefrontRequestHeaders(headers) {\n applyStorefrontRequestHeaders(context, headers);\n },\n captureSubrequestHeaders,\n consumeStorefrontResponseHeaders(headers) {\n captureSubrequestHeaders(headers);\n headers.delete(\"set-cookie\");\n },\n markResponseAsPersonalized(reason) {\n personalizedResponseReason ??= reason;\n },\n markResponseAsSessionEstablishing(reason) {\n sessionEstablishingReason ??= reason;\n },\n applyResponseHeaders(headers) {\n headers.set(\"powered-by\", \"Shopify, Hydrogen\");\n\n // Documents may be shared or streamed, so they must not carry buyer-specific state.\n const isDocumentResponse =\n context.documentRequest || (headers.get(\"content-type\")?.startsWith(\"text/html\") ?? false);\n\n // Keep GET and HEAD responses cacheable, and fail closed when the request method is unknown.\n // Cold sessions may only be established by the explicitly marked consent request.\n const mayReturnShopifyState =\n requestMethod !== undefined &&\n requestMethod !== \"GET\" &&\n requestMethod !== \"HEAD\" &&\n !isDocumentResponse &&\n (hasEssentialCookie ||\n isConsentManagementRequest ||\n sessionEstablishingReason !== undefined);\n\n // Replay cookies captured from fresh SFAPI and proxy responses when allowed.\n if (capturedCookies && mayReturnShopifyState) {\n const existingSetCookies = new Set(headers.getSetCookie());\n for (const value of capturedCookies) {\n if (existingSetCookies.has(value)) continue;\n headers.append(\"set-cookie\", value);\n existingSetCookies.add(value);\n }\n }\n\n // Consent/session responses can contain private state in the body without setting cookies.\n if (\n personalizedResponseReason ||\n isConsentManagementRequest ||\n sessionEstablishingReason !== undefined ||\n headers.has(\"set-cookie\")\n ) {\n applyPrivateResponseCacheHeaders(headers);\n }\n },\n } as ShopifyRequestContext<I18n>;\n}\n\nfunction normalizeI18n<I18n extends I18nConfig>(i18n: I18n): NormalizedI18nConfig<I18n> {\n return {\n ...i18n,\n pathPrefix: normalizePathPrefix(i18n.pathPrefix),\n } as NormalizedI18nConfig<I18n>;\n}\n\nfunction applyStorefrontRequestHeaders(context: Context, headers: Headers): void {\n headers.set(SDK_VARIANT_HEADER, \"hydrogen\");\n headers.set(SDK_VARIANT_SOURCE_HEADER, \"kit\");\n headers.set(SDK_VERSION_HEADER, STOREFRONT_API_VERSION);\n headers.set(HYDROGEN_VERSION_HEADER, __HYDROGEN_VERSION__);\n headers.set(REQUEST_GROUP_ID_HEADER, context.requestGroupId);\n\n if (context.cookie) headers.set(\"cookie\", context.cookie);\n else headers.delete(\"cookie\");\n if (context.globalPrivacyControl !== undefined) {\n headers.set(SEC_GPC_HEADER, context.globalPrivacyControl);\n } else headers.delete(SEC_GPC_HEADER);\n if (context.storefrontOrigin) {\n headers.set(SHOPIFY_STOREFRONT_ORIGIN_HEADER, context.storefrontOrigin);\n } else headers.delete(SHOPIFY_STOREFRONT_ORIGIN_HEADER);\n}\n\nfunction getUrlOrigin(url: string | undefined): string | undefined {\n if (!url) return undefined;\n try {\n return new URL(url).origin;\n } catch {\n return undefined;\n }\n}\n\nfunction isDocumentRequest(request: StorefrontRequest): boolean {\n if (request.method && request.method !== \"GET\" && request.method !== \"HEAD\") return false;\n\n const destination = request.headers.get(\"sec-fetch-dest\");\n if (destination === \"document\") return true;\n\n return request.headers.get(\"accept\")?.includes(\"text/html\") ?? false;\n}\n\nfunction parseCookieHeader(cookieHeader: string | undefined): Map<string, string> {\n const cookies = new Map<string, string>();\n if (!cookieHeader) return cookies;\n\n for (const cookie of cookieHeader.split(\";\")) {\n const separator = cookie.indexOf(\"=\");\n if (separator < 0) continue;\n\n const name = cookie.slice(0, separator).trim();\n if (!cookies.has(name)) cookies.set(name, cookie.slice(separator + 1));\n }\n\n return cookies;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"request-context.mjs","names":[],"sources":["../../../src/core/request-context.ts"],"sourcesContent":["import type {\n CountryCode as CustomerAccountCountryCode,\n LanguageCode as CustomerAccountLanguageCode,\n} from \"../graphql/generated/customer-account-api-types\";\nimport type {\n CountryCode as StorefrontCountryCode,\n LanguageCode as StorefrontLanguageCode,\n} from \"../graphql/generated/storefront-api-types\";\nimport { STOREFRONT_API_VERSION } from \"./constants\";\nimport {\n applyPrivateResponseCacheHeaders,\n CONSENT_MANAGEMENT_HEADER,\n HYDROGEN_VERSION_HEADER,\n REQUEST_GROUP_ID_HEADER,\n SDK_VARIANT_HEADER,\n SDK_VARIANT_SOURCE_HEADER,\n SDK_VERSION_HEADER,\n SEC_GPC_HEADER,\n SHOPIFY_STOREFRONT_ORIGIN_HEADER,\n STOREFRONT_URL_HEADER,\n} from \"./headers\";\nimport { normalizePathPrefix } from \"./standard-routes/path\";\n\nconst SHOPIFY_ESSENTIAL_COOKIE = \"_shopify_essential\";\n\n/** The incoming request's headers, plus its method, abort signal, and URL when the framework provides them. */\ntype StorefrontRequest = Pick<Request, \"headers\"> &\n Partial<Pick<Request, \"method\" | \"signal\" | \"url\">>;\n\n/** A language code that both the Storefront API and the Customer Account API accept. */\nexport type ShopifyLanguageCode = Extract<StorefrontLanguageCode, CustomerAccountLanguageCode>;\n/** A country code that both the Storefront API and the Customer Account API accept. */\nexport type ShopifyCountryCode = Extract<StorefrontCountryCode, CustomerAccountCountryCode>;\n\n/** The language, country, and optional localized path prefix for a request. */\nexport type I18nConfig = {\n /** The request's language. */\n language: ShopifyLanguageCode;\n /** The request's country. */\n country: ShopifyCountryCode;\n /**\n * The URL prefix for localized routes, such as `/es-es`. Hydrogen adds the prefix to standard route redirects and product variant URLs.\n */\n pathPrefix?: string;\n};\n\n/** The request's locale, with a path prefix that starts with `/` or is an empty string. */\ntype NormalizedI18nConfig<I18n extends I18nConfig = I18nConfig> = Omit<I18n, \"pathPrefix\"> & {\n pathPrefix: string;\n};\n\n/** The incoming request and its locale. */\ntype ShopifyRequestContextInputBase<I18n extends I18nConfig = I18nConfig> = {\n /**\n * The incoming request from your framework. When the framework exposes no request, such as during prerendering, pass `{ headers: new Headers() }`.\n */\n request: StorefrontRequest;\n /** The request's language, country, and optional localized path prefix. */\n i18n: I18n;\n};\n\ntype ShopifyRequestContextInput<I18n extends I18nConfig = I18nConfig> =\n ShopifyRequestContextInputBase<I18n> & { buyerIp?: never };\n\ntype ShopifyRequestContextWithBuyerIpInput<I18n extends I18nConfig = I18nConfig> =\n ShopifyRequestContextInputBase<I18n> & { buyerIp: string };\n\n/** The fields and methods on every request context. */\ntype ShopifyRequestContextBase = {\n // -- Private fields --\n /**\n * A type-only marker. Create request contexts only with `createShopifyRequestContext`.\n * @internal\n */\n readonly __hydrogenShopifyRequestContextBrand: never;\n /**\n * The incoming request's `Cookie` header. Hydrogen forwards the header to the Storefront API.\n * @internal\n */\n cookie?: string;\n /**\n * The customer's trusted IP address. Private Storefront API clients require it.\n * @internal\n */\n readonly buyerIp?: string;\n /**\n * The ID that groups this request's Shopify API calls. Hydrogen reads the ID from the request headers or generates one.\n * @internal\n */\n requestGroupId: string;\n /**\n * The incoming request's abort signal. Hydrogen's API clients stop their requests when the signal aborts.\n * @internal\n */\n signal?: AbortSignal;\n /**\n * The incoming request's URL.\n * @internal\n */\n url?: string;\n /**\n * The origin of the incoming request's URL. Hydrogen forwards the origin to the Storefront API.\n * @internal\n */\n storefrontOrigin?: string;\n /**\n * Adds the Hydrogen SDK headers and the customer's cookie, privacy, and origin headers to a\n * Storefront API request. The Storefront API client and the API proxies call the method for you.\n * @internal\n */\n applyStorefrontRequestHeaders(headers: Headers): void;\n /**\n * Saves the cookies from the first Storefront API response that sets cookies. The Storefront API\n * client calls the method for you, and `applyResponseHeaders()` can add the cookies to your response.\n * @internal\n */\n captureSubrequestHeaders(headers: Headers): void;\n /**\n * Saves the cookies from a proxied Storefront API response and removes the `set-cookie` header\n * from that response. The API proxies in `handleShopifyRoutes` call the method for you.\n * @internal\n */\n consumeStorefrontResponseHeaders(headers: Headers): void;\n /**\n * Marks the final response as private and uncacheable because the response holds customer data.\n * The Customer Account API client and the customer session call the method for you.\n * @internal\n */\n markResponseAsPersonalized(reason: string): void;\n /**\n * Lets the final response set Shopify cookies before the customer has a Shopify session, and\n * marks the response as private and uncacheable. The UCP MCP proxy calls the method for you.\n * @internal\n */\n markResponseAsSessionEstablishing(reason: string): void;\n\n // -- Public fields --\n /** The request's language, country, and path prefix. The path prefix starts with `/` and has no trailing slash, or is an empty string when you don't set one. */\n i18n: NormalizedI18nConfig;\n /** Returns the incoming request's headers with Shopify's request headers added. Pass the headers when you forward the request to a proxy or another origin. */\n getForwardedRequestHeaders(): Headers;\n /**\n * Adds the headers that a Hydrogen storefront needs to the final response. Call the method on each response that your app builds. Responses from `handleShopifyRoutes` and `handleShopifyRedirects` already carry these headers.\n *\n * Append your committed session headers before you call the method. The method makes any response that sets a cookie private and uncacheable. The method also sets the `powered-by` header, makes responses that hold customer data private and uncacheable, and adds eligible Shopify cookies only to non-document responses for methods other than `GET` and `HEAD`.\n */\n applyResponseHeaders(headers: Headers): void;\n};\n\n/** The context for one request. Pass the context to Hydrogen's API clients and route handlers for that request. */\nexport type ShopifyRequestContext<I18n extends I18nConfig = I18nConfig> =\n ShopifyRequestContextBase & {\n i18n: NormalizedI18nConfig<I18n>;\n };\n\n/** A request context that includes the customer's IP address. Private Storefront API clients require this context. */\nexport type ShopifyRequestContextWithBuyerIp<I18n extends I18nConfig = I18nConfig> =\n ShopifyRequestContext<I18n> & { readonly buyerIp: string };\n\n/** The values that the request context reads from the incoming request. */\ntype Context<I18n extends I18nConfig = I18nConfig> = {\n /** The incoming request's `Cookie` header. */\n cookie?: string;\n /** The incoming request's `Sec-GPC` header value, which carries the customer's Global Privacy Control signal. */\n globalPrivacyControl?: string;\n /** The customer's trusted IP address. */\n buyerIp?: string;\n /** The ID that groups this request's Shopify API calls. Hydrogen reads the ID from the request headers or generates one. */\n requestGroupId: string;\n /** The incoming request's abort signal. */\n signal?: AbortSignal;\n /** The incoming request's URL. */\n url?: string;\n /** The origin of the incoming request's URL. */\n storefrontOrigin?: string;\n /** The request's language, country, and path prefix. */\n i18n: NormalizedI18nConfig<I18n>;\n /** Whether the incoming request asks for an HTML document. */\n documentRequest?: boolean;\n};\n\n/**\n * Creates the context for one incoming request. Pass the same context to the Storefront API\n * client, the Customer Account API client, and `handleShopifyRoutes`. The context forwards the\n * customer's cookies, locale, and privacy signal to Shopify, and adds Shopify's cookies and cache\n * headers to your response.\n *\n * Call `applyResponseHeaders()` on each response that your app builds. Pass `buyerIp` with the customer's trusted IP address when you use a private Storefront API client. The function throws when `i18n` lacks a country or language, and when `buyerIp` is an empty string.\n *\n * @publicDocs\n */\nexport function createShopifyRequestContext<const I18n extends I18nConfig>(\n input: ShopifyRequestContextWithBuyerIpInput<I18n>,\n): ShopifyRequestContextWithBuyerIp<I18n>;\nexport function createShopifyRequestContext<const I18n extends I18nConfig>(\n input: ShopifyRequestContextInput<I18n>,\n): ShopifyRequestContext<I18n>;\n/**\n * @param input The incoming request, its locale, and the customer's IP address for private Storefront API clients.\n * @returns The context to pass to Hydrogen's API clients and route handlers for this request.\n */\nexport function createShopifyRequestContext<const I18n extends I18nConfig>(\n input: ShopifyRequestContextInputBase<I18n> & { buyerIp?: string },\n): ShopifyRequestContext<I18n> {\n const { request } = input;\n\n if (!input.i18n?.country || !input.i18n?.language) {\n throw new Error(\"i18n with country and language is required for Shopify request contexts.\");\n }\n\n if (input.buyerIp !== undefined && !input.buyerIp) {\n throw new Error(\"buyerIp must be non-empty when provided\");\n }\n\n const requestMethod = request.method?.toUpperCase();\n const i18n = normalizeI18n(input.i18n);\n const cookieHeader = request.headers.get(\"cookie\") || undefined;\n const inboundCookies = parseCookieHeader(cookieHeader);\n const hasEssentialCookie = inboundCookies.has(SHOPIFY_ESSENTIAL_COOKIE);\n const isConsentManagementRequest = request.headers.get(CONSENT_MANAGEMENT_HEADER) === \"1\";\n const url = request.url ?? request.headers.get(STOREFRONT_URL_HEADER) ?? undefined;\n const storefrontOrigin = getUrlOrigin(url);\n const context = {\n ...(cookieHeader && { cookie: cookieHeader }),\n globalPrivacyControl: request.headers.get(SEC_GPC_HEADER) ?? undefined,\n i18n,\n ...(url && { url }),\n ...(storefrontOrigin && { storefrontOrigin }),\n ...(input.buyerIp && { buyerIp: input.buyerIp }),\n requestGroupId:\n request.headers.get(REQUEST_GROUP_ID_HEADER) ??\n request.headers.get(\"x-request-id\") ??\n request.headers.get(\"request-id\") ??\n crypto.randomUUID(),\n ...(isDocumentRequest(request) && { documentRequest: true }),\n ...(request.signal && { signal: request.signal }),\n } as Context<I18n>;\n\n let capturedCookies: string[] | undefined;\n let personalizedResponseReason: string | undefined;\n let sessionEstablishingReason: string | undefined;\n\n const captureSubrequestHeaders = (headers: Headers): void => {\n // Capture the first fresh response cookies to increase the\n // chance of returning them from the main server response. The main response\n // needs headers set at send time, while the body can stream later, so this\n // may not be used if subrequests finish after the main response is sent.\n const cookies = headers.getSetCookie();\n if (cookies.length > 0) capturedCookies ??= cookies;\n };\n\n return {\n ...context,\n getForwardedRequestHeaders() {\n const headers = new Headers(request.headers);\n applyStorefrontRequestHeaders(context, headers);\n if (context.url) headers.set(STOREFRONT_URL_HEADER, context.url);\n return headers;\n },\n applyStorefrontRequestHeaders(headers) {\n applyStorefrontRequestHeaders(context, headers);\n },\n captureSubrequestHeaders,\n consumeStorefrontResponseHeaders(headers) {\n captureSubrequestHeaders(headers);\n headers.delete(\"set-cookie\");\n },\n markResponseAsPersonalized(reason) {\n personalizedResponseReason ??= reason;\n },\n markResponseAsSessionEstablishing(reason) {\n sessionEstablishingReason ??= reason;\n },\n applyResponseHeaders(headers) {\n headers.set(\"powered-by\", \"Shopify, Hydrogen\");\n\n // Documents may be shared or streamed, so they must not carry buyer-specific state.\n const isDocumentResponse =\n context.documentRequest || (headers.get(\"content-type\")?.startsWith(\"text/html\") ?? false);\n\n // Keep GET and HEAD responses cacheable, and fail closed when the request method is unknown.\n // Cold sessions may only be established by the explicitly marked consent request.\n const mayReturnShopifyState =\n requestMethod !== undefined &&\n requestMethod !== \"GET\" &&\n requestMethod !== \"HEAD\" &&\n !isDocumentResponse &&\n (hasEssentialCookie ||\n isConsentManagementRequest ||\n sessionEstablishingReason !== undefined);\n\n // Replay cookies captured from fresh SFAPI and proxy responses when allowed.\n if (capturedCookies && mayReturnShopifyState) {\n const existingSetCookies = new Set(headers.getSetCookie());\n for (const value of capturedCookies) {\n if (existingSetCookies.has(value)) continue;\n headers.append(\"set-cookie\", value);\n existingSetCookies.add(value);\n }\n }\n\n // Consent/session responses can contain private state in the body without setting cookies.\n if (\n personalizedResponseReason ||\n isConsentManagementRequest ||\n sessionEstablishingReason !== undefined ||\n headers.has(\"set-cookie\")\n ) {\n applyPrivateResponseCacheHeaders(headers);\n }\n },\n } as ShopifyRequestContext<I18n>;\n}\n\nfunction normalizeI18n<I18n extends I18nConfig>(i18n: I18n): NormalizedI18nConfig<I18n> {\n return {\n ...i18n,\n pathPrefix: normalizePathPrefix(i18n.pathPrefix),\n } as NormalizedI18nConfig<I18n>;\n}\n\nfunction applyStorefrontRequestHeaders(context: Context, headers: Headers): void {\n headers.set(SDK_VARIANT_HEADER, \"hydrogen\");\n headers.set(SDK_VARIANT_SOURCE_HEADER, \"kit\");\n headers.set(SDK_VERSION_HEADER, STOREFRONT_API_VERSION);\n headers.set(HYDROGEN_VERSION_HEADER, __HYDROGEN_VERSION__);\n headers.set(REQUEST_GROUP_ID_HEADER, context.requestGroupId);\n\n if (context.cookie) headers.set(\"cookie\", context.cookie);\n else headers.delete(\"cookie\");\n if (context.globalPrivacyControl !== undefined) {\n headers.set(SEC_GPC_HEADER, context.globalPrivacyControl);\n } else headers.delete(SEC_GPC_HEADER);\n if (context.storefrontOrigin) {\n headers.set(SHOPIFY_STOREFRONT_ORIGIN_HEADER, context.storefrontOrigin);\n } else headers.delete(SHOPIFY_STOREFRONT_ORIGIN_HEADER);\n}\n\nfunction getUrlOrigin(url: string | undefined): string | undefined {\n if (!url) return undefined;\n try {\n return new URL(url).origin;\n } catch {\n return undefined;\n }\n}\n\nfunction isDocumentRequest(request: StorefrontRequest): boolean {\n if (request.method && request.method !== \"GET\" && request.method !== \"HEAD\") return false;\n\n const destination = request.headers.get(\"sec-fetch-dest\");\n if (destination === \"document\") return true;\n\n return request.headers.get(\"accept\")?.includes(\"text/html\") ?? false;\n}\n\nfunction parseCookieHeader(cookieHeader: string | undefined): Map<string, string> {\n const cookies = new Map<string, string>();\n if (!cookieHeader) return cookies;\n\n for (const cookie of cookieHeader.split(\";\")) {\n const separator = cookie.indexOf(\"=\");\n if (separator < 0) continue;\n\n const name = cookie.slice(0, separator).trim();\n if (!cookies.has(name)) cookies.set(name, cookie.slice(separator + 1));\n }\n\n return cookies;\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -6,12 +6,12 @@ Read `package.json` in the current directory.
|
|
|
6
6
|
|
|
7
7
|
If it does not exist, stop and tell the user this skill must run in a javascript application with a `package.json` at its root.
|
|
8
8
|
|
|
9
|
-
If it does not contain `@shopify/hydrogen
|
|
9
|
+
If it does not contain `@shopify/hydrogen` 2026.10.0 or later in its dependencies, ask the user to install it. Do not assume package managers or install for the user.
|
|
10
10
|
|
|
11
11
|
### Continue when
|
|
12
12
|
|
|
13
13
|
- [ ] Current directory has `package.json`
|
|
14
|
-
- [ ] Current project includes `@shopify/hydrogen
|
|
14
|
+
- [ ] Current project includes `@shopify/hydrogen` 2026.10.0 or later in its dependencies
|
|
15
15
|
|
|
16
16
|
## Styling
|
|
17
17
|
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: migrate-to-hydrogen-2026-10
|
|
3
|
+
description: >
|
|
4
|
+
Migrate a classic Hydrogen storefront (Remix or React Router 7) to Hydrogen
|
|
5
|
+
2026-10 (`@shopify/hydrogen@^2026.10.0`), where Hydrogen is a server-handler
|
|
6
|
+
library you compose instead of a framework that owns the request. Use when
|
|
7
|
+
the user asks to migrate or upgrade a Hydrogen storefront to Hydrogen 2026-10
|
|
8
|
+
("migrate to hydrogen 2026-10", "migrate to hydrogen preview", "upgrade
|
|
9
|
+
Hydrogen to the new library").
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Migrate to Hydrogen 2026-10
|
|
13
|
+
|
|
14
|
+
Migrate a classic-Hydrogen storefront (Remix- or React-Router-based) to Hydrogen 2026-10 — the release where Hydrogen becomes a server-handler library. Install `@shopify/hydrogen@^2026.10.0` (`npm view @shopify/hydrogen dist-tags` shows the current `latest`; do not install the `preview` dist-tag, which is older than `latest`). This skill is storefront-agnostic — it captures the migration shape and, most importantly, the **traps that agents get wrong**.
|
|
15
|
+
|
|
16
|
+
## The mental model (get this right first)
|
|
17
|
+
|
|
18
|
+
1. **Hydrogen 2026-10 is a server-handler library, not a framework.** (A `@shopify/hydrogen` version **before `2026.10` is still the old framework**, not the library — the classic line runs through `2026.4.x` — so an installed version below `2026.10` means the store hasn't been migrated yet, regardless of its React Router status.) You compose the request lifecycle yourself: `createShopifyRequestContext({request, i18n, buyerIp})` → `createStorefrontClient({type: 'private', requestContext, config})` (config carries `storeDomain`, `privateStorefrontToken`, `storefrontId`, `cache`, `waitUntil`; a store without a private token uses `type: 'public'` instead — see the `hydrogen-storefront-client` packaged skill for the choice) → `handleShopifyRoutes({request, requestContext, sessionManager, storefrontClient, routeTemplates, handlers})` (returns a `Promise<Response>` for handler-owned paths, else `null` — decided synchronously, so truthy-check it without `await`) → `handleShopifyRedirects` on 404 → handler groups for the rest: `createCartServerHandlers()` for cart and `createCustomerAccountServerHandlers(…)` (from `@shopify/hydrogen/customer-account`) for auth, both registered through the `handlers` array. Hydrogen no longer owns routing or the entry — you wire these into your React Router server/middleware.
|
|
19
|
+
|
|
20
|
+
2. **Dev and build are plain Vite/React Router — the classic `shopify hydrogen dev|build` commands and the `hydrogen()` Vite plugin are gone.** `dev` is `vite dev` with the `oxygen()` plugin from `@shopify/mini-oxygen/vite` (workerd runtime, `.env` injection); `build` is `react-router build`; `preview` is `vite preview` after a build. The `hydrogen` bin that ships with the library covers the rest: `hydrogen setup`, `hydrogen skills check|sync`, `hydrogen gql check`, `npx hydrogen certs install|uninstall` for local HTTPS. `@shopify/cli` stays a devDependency for the `shopify hydrogen` commands the library does not replace: `deploy`, `link`, `env pull`. Do **not** add `@shopify/hydrogen-classic` or any aliased classic package — the 2026-10 library has no classic sibling.
|
|
21
|
+
|
|
22
|
+
Before wiring any subsystem, read the matching packaged skill in `node_modules/@shopify/hydrogen/skills/` (`hydrogen-setup`, `hydrogen-request-handlers`, `hydrogen-routing`, `hydrogen-storefront-client`, `hydrogen-cart-ui`, `hydrogen-cart-drawer`, `hydrogen-collection-browser`, `hydrogen-variant-form`, `hydrogen-predictive-search`, `hydrogen-customer-account`, `hydrogen-analytics`, `hydrogen-markets`, `hydrogen-shop-pay`, …). They are the authoritative, version-correct reference — prefer them over memory.
|
|
23
|
+
|
|
24
|
+
## Migration outline
|
|
25
|
+
|
|
26
|
+
**First, determine whether the Remix→React Router move is already done.** The Remix→React Router migration and the Hydrogen-framework→Hydrogen-library migration are **independent axes**. The classic Hydrogen framework runs on **either** Remix or React Router 7, so a store can already be on React Router while still being unmigrated. Check `package.json` and imports: if there are no `@remix-run/react`, `@remix-run/node`, `@remix-run/server-runtime` or `@shopify/remix-oxygen` deps or imports and `react-router` + `@react-router/*` are present (with a `react-router.config.ts`), the RR migration is already complete (`@remix-run/cookie` and `@remix-run/session` are standalone packages the 2026-10 template itself uses for cookie sessions — not a Remix tell) — **skip the Remix swaps (steps 1 and 6 below) and do only the framework→library conversion.** "Already on React Router" does **not** mean "already migrated." The tell for the Hydrogen axis is the package: a `@shopify/hydrogen` calendar version **before `2026.10` is still classic (framework) Hydrogen** — not yet migrated (the latest classic releases are on the `2026.4.x` line).
|
|
27
|
+
|
|
28
|
+
Do these in order; verify each against the installed package rather than assuming. Copy this checklist and track progress as you go:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
Hydrogen migration:
|
|
32
|
+
- [ ] Determine if already on React Router (if so, skip the Remix swaps in steps 1 and 6)
|
|
33
|
+
- [ ] 1. Dependencies + version pins (@shopify/hydrogen ^2026.10.0 and the seed below; drop classic-era deps)
|
|
34
|
+
- [ ] 2. Scripts: hydrogen skills check --mode=warn && vite dev / react-router build / vite preview; deploy via @shopify/cli
|
|
35
|
+
- [ ] 3. vite.config plugins: [localHttps({enabled}), ...oxygen(), reactRouter()]
|
|
36
|
+
- [ ] 4. react-router.config.ts added (v8_middleware on, buildDirectory 'dist')
|
|
37
|
+
- [ ] 5. Request wiring (request context + storefront client + route templates + session manager)
|
|
38
|
+
- [ ] 6. Route filename + import + json()/defer() sweep
|
|
39
|
+
- [ ] CSP/nonce: classic's utilities are gone — port CSP/nonce as app code, then check the browser console for CSP violations
|
|
40
|
+
- [ ] 7. Connect a store — env vars in .env (private token, or public token), or MOCK_SHOP=1 for the tokenless mock.shop demo
|
|
41
|
+
- [ ] Optional subsystems: detect each → migrate or skip, and report every skip
|
|
42
|
+
- [ ] 8. Verify (build/typecheck gate, hydrogen-smoke-test, browser console on the home page), then hand off to the user
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
1. **Dependencies** — *(Remix swap only if not already on React Router)* replace `@remix-run/react`, `@remix-run/node`, `@remix-run/server-runtime`, `@remix-run/dev` and `@shopify/remix-oxygen` with `react-router` + `@react-router/dev` (pin all React Router packages to one version; `react-router-dom` is not needed). Add `@shopify/hydrogen`, `@shopify/mini-oxygen`, `vite`, the `@types/*` packages and `@shopify/cli` at the versions in the seed below. Remove `@shopify/cli-hydrogen` and any `@shopify/hydrogen-classic`-style alias if present. Align versions to the seed rather than picking latest of each independently, and never pin React Router below the version the app already runs.
|
|
46
|
+
2. **Scripts** — `dev`: `hydrogen skills check --mode=warn && vite dev` (the prefix prints the resync command after a later `@shopify/hydrogen` bump without blocking dev; plain `hydrogen skills check` belongs in CI); `build`: `react-router build`; `preview`: `react-router build && vite preview`; `deploy`: `shopify hydrogen deploy --assets-dir dist/client --worker-dir dist/server`. Add `dev:https` with the **same** command — the vite.config computes the `localHttps` `enabled` flag from `npm_lifecycle_event` (step 3), not the script body.
|
|
47
|
+
3. **`vite.config`** — plugins are `[localHttps({enabled}), ...oxygen(), reactRouter()]`, with `localHttps` from `@shopify/hydrogen/vite` and `oxygen` from `@shopify/mini-oxygen/vite` (it returns an **array** — spread it). `localHttps` takes a **required** `enabled: boolean` — it does not self-detect; compute it in the config: `const enabled = process.env.npm_lifecycle_event === 'dev:https'`. There is no `hydrogen()` plugin anymore. You do **not** pass an oxygen `entry` — it picks up the React Router server build.
|
|
48
|
+
4. **`react-router.config.ts`** — add it (React Router framework config + future flags; `v8_middleware: true` is required for the middleware skeleton below). Set `buildDirectory: 'dist'` — React Router defaults to `build/`, and the deploy script's `--assets-dir dist/client --worker-dir dist/server` flags silently point at nothing without it. Explicit `routes.ts` route config and flat routes via `@react-router/fs-routes` both work — for a migration, fs-routes preserves the existing flat route filenames with the least churn.
|
|
49
|
+
5. **Request wiring** — in your server entry or a root middleware, build the request context and clients (mental-model step 1) and expose the storefront client to loaders via typed React Router contexts. If you already thread Oxygen `env`/`executionContext` through a context provider, keep it — the library does not force a specific context shape.
|
|
50
|
+
6. **Routes & imports** — *(skip the import swap if already on React Router)* swap `@remix-run/*` imports to `react-router`; migrate `json()`/`defer()` loader/action return helpers.
|
|
51
|
+
7. **Connect a store** — set `PUBLIC_STORE_DOMAIN`, `PUBLIC_STOREFRONT_ID` and `PRIVATE_STOREFRONT_API_TOKEN` in `.env` (on Oxygen, a linked storefront injects them); a store with only `PUBLIC_STOREFRONT_API_TOKEN` still uses the real store through the public client. Customer accounts add `SHOP_ID`, `PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID` and `SESSION_SECRET` (full list in `references/dependencies-and-tooling.md`). With `MOCK_SHOP=1`, or no store domain at all, the skeleton's client factory falls back to the tokenless mock.shop demo store — useful to verify the migration before the user supplies credentials. mock.shop only accepts a **tokenless public client** (it rejects the private-token header with a 401), and it is a catalog of fictional stores: https://mock.shop/llms.txt lists them.
|
|
52
|
+
8. **Verify** — build/typecheck gate, then the `hydrogen-smoke-test` skill, then the browser console check, then hand off to the user (see Verification).
|
|
53
|
+
|
|
54
|
+
The exact config, version pins, and handler signatures change between builds — read them from the installed package (`node_modules/@shopify/hydrogen`, its `skills/`, and its `.d.ts`), not from this doc. The two things that are **not in the installed package** — the version seed and the React Router wiring skeleton — are below.
|
|
55
|
+
|
|
56
|
+
## Bootstrapping versions
|
|
57
|
+
|
|
58
|
+
Step 1 has a chicken-and-egg problem: you cannot "read pins from the installed package" before anything is installed. The version *identities* are a bootstrap input, distinct from the config *shapes* you read post-install. Pin by these coupling rules, not by "latest of each":
|
|
59
|
+
|
|
60
|
+
- **One React Router version** across `react-router`, `@react-router/dev` (and `@react-router/fs-routes` if you use it).
|
|
61
|
+
- **Floors with a reason** — `@shopify/mini-oxygen` ≥ `4.2.0` (`vite preview`) and `@shopify/cli` ≥ `4.4.0` (deploy flags); `references/dependencies-and-tooling.md` explains both. The seed already satisfies them.
|
|
62
|
+
- Majors that move together: `vite@^8`, `react`/`react-dom@^19`, node `^22.12 || ^24` (Vite 8's own engine floor for Node 22 is `22.12.0` — a bare `22.x` below that installs but fails Vite's engine check).
|
|
63
|
+
- **Align to the seed, not to "latest of each".** Newer majors of the seed's packages can exist on npm (React Router 8 does) while the library's own template still pins the seed line — a solo bump breaks the coupling rules above. The one exception: never pin React Router *below* what the app already runs.
|
|
64
|
+
|
|
65
|
+
Known-good seed, copied from the 2026-10 release's own React Router template — **verify against the current published build and update this block in place when it moves**:
|
|
66
|
+
|
|
67
|
+
```jsonc
|
|
68
|
+
// package.json — align to a known-good release set (the 2026-10 template)
|
|
69
|
+
"@shopify/hydrogen": "^2026.10.0",
|
|
70
|
+
"react" | "react-dom": "^19.2.7", "react-router": "7.15.1", "isbot": "^5.1.36",
|
|
71
|
+
// devDependencies:
|
|
72
|
+
"@react-router/dev": "7.15.1", // same version as react-router; never below the app's current one
|
|
73
|
+
"@shopify/cli": "4.8.1",
|
|
74
|
+
"@shopify/mini-oxygen": "4.2.3",
|
|
75
|
+
"@shopify/oxygen-workers-types": "^4.1.6",
|
|
76
|
+
"@types/node": "^22", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3",
|
|
77
|
+
"gql.tada": "^1.9.2", "graphql": "^16.13.2", "typescript": "^5.9.3", "vite": "^8.0.3",
|
|
78
|
+
"engines.node": "^22.12 || ^24" // Vite 8 requires >=22.12 on the 22 line
|
|
79
|
+
// scripts:
|
|
80
|
+
"dev": "hydrogen skills check --mode=warn && vite dev",
|
|
81
|
+
"dev:https": "hydrogen skills check --mode=warn && vite dev", // same command — vite.config computes localHttps `enabled` from npm_lifecycle_event
|
|
82
|
+
"build": "react-router build",
|
|
83
|
+
"preview": "react-router build && vite preview",
|
|
84
|
+
"deploy": "shopify hydrogen deploy --assets-dir dist/client --worker-dir dist/server",
|
|
85
|
+
"typecheck": "react-router typegen && tsc && hydrogen gql check --fail-on-warn"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Reference wiring (skeleton)
|
|
89
|
+
|
|
90
|
+
The framework→library conversion is mostly **one context file plus one middleware**. A store won't have a prior migration to copy from, so the durable *shape* is below — but verify every signature against the installed `.d.ts`, since they drift between builds.
|
|
91
|
+
|
|
92
|
+
`server.ts` shrinks to a bare fetch handler that only threads the Oxygen bindings (the template wraps this in a try/catch and adds a GET-body guard and a double-slash redirect):
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import {createRequestHandler, RouterContextProvider} from 'react-router';
|
|
96
|
+
import * as serverBuild from 'virtual:react-router/server-build';
|
|
97
|
+
import {envContext} from '~/lib/storefront';
|
|
98
|
+
|
|
99
|
+
export default {
|
|
100
|
+
async fetch(request: Request, env: Env, executionContext: ExecutionContext): Promise<Response> {
|
|
101
|
+
const routerContext = new RouterContextProvider();
|
|
102
|
+
routerContext.cache = await caches.open('hydrogen-v1'); // Oxygen Cache API → storefront client cache
|
|
103
|
+
routerContext.set(envContext, env);
|
|
104
|
+
routerContext.env = env;
|
|
105
|
+
routerContext.waitUntil = executionContext.waitUntil.bind(executionContext);
|
|
106
|
+
const handleRequest = createRequestHandler(serverBuild, process.env.NODE_ENV);
|
|
107
|
+
return handleRequest(request, routerContext as never);
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`env.d.ts` — `routerContext.cache`/`waitUntil` above and `context.cache`/`waitUntil` in the middleware below only typecheck with this React Router module augmentation. It is app code, not something you can read from the package's `.d.ts`:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
/// <reference types="vite/client" />
|
|
116
|
+
/// <reference types="react-router" />
|
|
117
|
+
/// <reference types="@shopify/oxygen-workers-types" />
|
|
118
|
+
import type {CacheInstance} from '@shopify/hydrogen';
|
|
119
|
+
|
|
120
|
+
declare global {
|
|
121
|
+
interface Env { // the Oxygen bindings (`.env` locally)
|
|
122
|
+
MOCK_SHOP?: string;
|
|
123
|
+
PUBLIC_STORE_DOMAIN?: string;
|
|
124
|
+
PUBLIC_STOREFRONT_ID?: string;
|
|
125
|
+
PUBLIC_STOREFRONT_API_TOKEN?: string;
|
|
126
|
+
PRIVATE_STOREFRONT_API_TOKEN?: string;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
type HydrogenRouterContext = {cache: CacheInstance; env: Env; waitUntil: ExecutionContext['waitUntil']};
|
|
131
|
+
|
|
132
|
+
declare module 'react-router' {
|
|
133
|
+
interface AppLoadContext extends HydrogenRouterContext {}
|
|
134
|
+
interface RouterContextProvider extends HydrogenRouterContext {}
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`app/lib/storefront.ts` — typed RR contexts plus a per-request client factory (replaces the old `AppLoadContext`). `buyerIp` goes on the **request context**; `cache`/`waitUntil`/`storefrontId` go in the **client config**. The factory picks the **private** client when a private token exists, the **public** client against the real store otherwise (a public token works; tokenless can't read some fields), and the tokenless **public** client for mock.shop only when `MOCK_SHOP=1` or no store domain is set:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import {createContext} from 'react-router';
|
|
142
|
+
import {
|
|
143
|
+
type CacheInstance,
|
|
144
|
+
type CachingStrategy,
|
|
145
|
+
createShopifyRequestContext,
|
|
146
|
+
createStorefrontClient,
|
|
147
|
+
type PublicStorefrontClient,
|
|
148
|
+
type RequestScopedPrivateStorefrontClient,
|
|
149
|
+
type ShopifyRequestContext,
|
|
150
|
+
} from '@shopify/hydrogen';
|
|
151
|
+
|
|
152
|
+
// The bare client types default `Extra` to `{}`, which erases the per-call `cache`
|
|
153
|
+
// option from `graphql()` — every `Cache.long()` call-site then fails typecheck
|
|
154
|
+
// with an opaque "cache does not exist in type 'MergedOptions'". Carry the
|
|
155
|
+
// option through the alias instead.
|
|
156
|
+
type CacheOptions = {cache?: CachingStrategy};
|
|
157
|
+
|
|
158
|
+
export type RequestStorefrontClient =
|
|
159
|
+
| RequestScopedPrivateStorefrontClient<CacheOptions>
|
|
160
|
+
| PublicStorefrontClient<CacheOptions>;
|
|
161
|
+
|
|
162
|
+
export const envContext = createContext<Env>();
|
|
163
|
+
export const storefrontClientContext = createContext<RequestStorefrontClient>();
|
|
164
|
+
export const storefrontRequestContext = createContext<ShopifyRequestContext>();
|
|
165
|
+
|
|
166
|
+
// getBuyerIp is NOT a library export — it is app code (the template ships it in app/lib/shop.ts)
|
|
167
|
+
function getBuyerIp(headers: Headers) {
|
|
168
|
+
for (const header of ['oxygen-buyer-ip', 'cf-connecting-ip', 'x-forwarded-for']) {
|
|
169
|
+
const ip = headers.get(header)?.split(',')[0]?.trim();
|
|
170
|
+
if (ip) return ip;
|
|
171
|
+
}
|
|
172
|
+
if (process.env.NODE_ENV !== 'production') return '127.0.0.1';
|
|
173
|
+
throw new Error('No buyer IP header on the request');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function createRequestStorefrontClient(
|
|
177
|
+
request: Request,
|
|
178
|
+
env: Env,
|
|
179
|
+
cache: CacheInstance,
|
|
180
|
+
waitUntil: ExecutionContext['waitUntil'],
|
|
181
|
+
): RequestStorefrontClient {
|
|
182
|
+
const i18n = {country: 'US', language: 'EN'} as const; // or derive from the URL/domain
|
|
183
|
+
|
|
184
|
+
// mock.shop is auth-free and 401s any token header, so it always gets the tokenless public client.
|
|
185
|
+
if (env.MOCK_SHOP === '1' || !env.PUBLIC_STORE_DOMAIN) {
|
|
186
|
+
return createStorefrontClient({
|
|
187
|
+
type: 'public',
|
|
188
|
+
requestContext: createShopifyRequestContext({request, i18n}),
|
|
189
|
+
config: {storeDomain: 'mock.shop', cache, waitUntil},
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (env.PRIVATE_STOREFRONT_API_TOKEN) {
|
|
194
|
+
return createStorefrontClient({
|
|
195
|
+
type: 'private',
|
|
196
|
+
requestContext: createShopifyRequestContext({request, i18n, buyerIp: getBuyerIp(request.headers)}),
|
|
197
|
+
config: {
|
|
198
|
+
storeDomain: env.PUBLIC_STORE_DOMAIN,
|
|
199
|
+
privateStorefrontToken: env.PRIVATE_STOREFRONT_API_TOKEN,
|
|
200
|
+
storefrontId: env.PUBLIC_STOREFRONT_ID,
|
|
201
|
+
cache,
|
|
202
|
+
waitUntil,
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Many stores run locally with only a public token — don't send them to mock.shop.
|
|
208
|
+
// Tokenless access can't read some fields (product tags, metaobjects, metafields, menus).
|
|
209
|
+
return createStorefrontClient({
|
|
210
|
+
type: 'public',
|
|
211
|
+
requestContext: createShopifyRequestContext({request, i18n}),
|
|
212
|
+
config: {
|
|
213
|
+
storeDomain: env.PUBLIC_STORE_DOMAIN,
|
|
214
|
+
publicStorefrontToken: env.PUBLIC_STOREFRONT_API_TOKEN,
|
|
215
|
+
storefrontId: env.PUBLIC_STOREFRONT_ID,
|
|
216
|
+
cache,
|
|
217
|
+
waitUntil,
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
`app/lib/route-templates.ts` — maps Shopify entities to your route shapes. Pass it to `handleShopifyRoutes` (optional in the type, but handler-owned URLs come out wrong without it), to `handleShopifyRedirects` (**required** there), and to the `routes` prop of `ShopifyScripts`:
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
import {createShopifyRouteTemplates} from '@shopify/hydrogen';
|
|
227
|
+
export const routeTemplates = createShopifyRouteTemplates({
|
|
228
|
+
productInCollection: '/products/:productHandle',
|
|
229
|
+
});
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
**Classic data-layer call-sites map mechanically:** `storefront.query()` → `client.graphql(QUERY, {variables, cache})`, which returns `{data, errors, headers}` instead of merging errors into the result — check `errors` before reading `data`; `$country`/`$language` are auto-injected, so drop them from `variables`; `storefront.CacheLong()` → the per-call `Cache.long()` (classic's implicit default caching is **gone**, so give every query that relied on it an explicit `cache`, `Cache.short()` or `Cache.long()`, or it hits the API on every request); `storefront.i18n` → `client.i18n`.
|
|
233
|
+
|
|
234
|
+
Root **middleware** in `app/root.tsx` (needs `future.v8_middleware`) — the request lifecycle from mental-model step 1:
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
import {handleShopifyRedirects, handleShopifyRoutes} from '@shopify/hydrogen';
|
|
238
|
+
import {cartHandlers} from '~/lib/cart-handlers';
|
|
239
|
+
import {routeTemplates} from '~/lib/route-templates';
|
|
240
|
+
import {createEphemeralSessionManager} from '~/lib/session';
|
|
241
|
+
import {
|
|
242
|
+
createRequestStorefrontClient, envContext, storefrontClientContext, storefrontRequestContext,
|
|
243
|
+
} from '~/lib/storefront';
|
|
244
|
+
import type {Route} from './+types/root';
|
|
245
|
+
|
|
246
|
+
export const middleware: Route.MiddlewareFunction[] = [
|
|
247
|
+
async ({context, request}, next) => {
|
|
248
|
+
const env = context.get(envContext);
|
|
249
|
+
const storefrontClient = createRequestStorefrontClient(request, env, context.cache, context.waitUntil);
|
|
250
|
+
const requestContext = storefrontClient.requestContext; // reuse — a fresh one throws in handleShopifyRoutes
|
|
251
|
+
const sessionManager = createEphemeralSessionManager(request);
|
|
252
|
+
|
|
253
|
+
const shopifyRoute = handleShopifyRoutes({ // handler-owned paths (e.g. /api/cart) return early
|
|
254
|
+
request, requestContext, sessionManager, storefrontClient, routeTemplates, handlers: [cartHandlers],
|
|
255
|
+
});
|
|
256
|
+
if (shopifyRoute) return shopifyRoute; // Promise<Response> or null — do NOT await the call
|
|
257
|
+
|
|
258
|
+
context.set(storefrontClientContext, storefrontClient);
|
|
259
|
+
context.set(storefrontRequestContext, requestContext);
|
|
260
|
+
|
|
261
|
+
const response = await next();
|
|
262
|
+
|
|
263
|
+
const sessionHeaders = new Headers((await sessionManager.commit?.()) ?? undefined);
|
|
264
|
+
for (const [name, value] of sessionHeaders) response.headers.append(name, value);
|
|
265
|
+
if (response.status === 404) { // 404 → Shopify URL redirects
|
|
266
|
+
const redirect = await handleShopifyRedirects({request, storefrontClient, routeTemplates});
|
|
267
|
+
if (redirect) return redirect; // headers already applied by the library
|
|
268
|
+
}
|
|
269
|
+
requestContext.applyResponseHeaders(response.headers); // MUST run or Shopify response headers vanish
|
|
270
|
+
return response;
|
|
271
|
+
},
|
|
272
|
+
];
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
`app/lib/session.ts` — the `sessionManager` contract is **four required methods** (item values are typed `unknown`, not `string`) plus an optional `commit()` that returns cookie headers, which the middleware above applies. A per-request in-memory map satisfies the contract and is enough for carts, but it **cannot complete Customer Account login**: the OAuth state is written on `/account/login` and read back on `/account/authorize`, two different requests. For customer accounts, copy the template's cookie-backed `createRequestSessionManager` (a signed `__Host-session` cookie built on `@remix-run/cookie` + `@remix-run/session`, keyed by `SESSION_SECRET`):
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
import type {WritableCustomerSessionManager} from '@shopify/hydrogen/customer-account';
|
|
279
|
+
|
|
280
|
+
export function createEphemeralSessionManager(request: Request): WritableCustomerSessionManager {
|
|
281
|
+
const data = new Map<string, unknown>();
|
|
282
|
+
const origin = new URL(request.url).origin;
|
|
283
|
+
return {
|
|
284
|
+
getSessionOrigin: () => origin,
|
|
285
|
+
getSessionItem: (key) => data.get(key),
|
|
286
|
+
setSessionItem: (key, value) => {
|
|
287
|
+
data.set(key, value);
|
|
288
|
+
},
|
|
289
|
+
removeSessionItem: (key) => {
|
|
290
|
+
data.delete(key);
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Supporting files: `app/lib/cart-handlers.ts` → `export const cartHandlers = createCartServerHandlers()` (options: `fragment` to extend the cart query, `customerSession` to sync buyer identity — see `references/cart.md`); `app/lib/cart.ts` → `createCartComponents<typeof cartHandlers>()` yields `CartProvider`/`useCart`/`useCartForm`; when the store has customer accounts, add the `createCustomerAccountServerHandlers({customerSession, …})` group next to `cartHandlers` in the middleware's `handlers` array and swap the session manager for the cookie-backed one (see `references/customer-account.md`); `react-router.config.ts` → `{buildDirectory: 'dist', future: {v8_middleware: true}}`.
|
|
297
|
+
|
|
298
|
+
Two lines in that middleware are easy to omit and fail **silently**: `applyResponseHeaders` on the normal response path (Shopify response headers disappear) and `routeTemplates` into both handler calls (handler-owned URLs and redirect targets come out wrong). A third, `buyerIp` into `createShopifyRequestContext`, fails **loudly** for the private client (`TypeError: requestContext.buyerIp is required for private Storefront API clients`) — keep the `getBuyerIp` helper rather than deleting the option to silence it. For the customer-account session wiring, follow the `hydrogen-customer-account` packaged skill rather than extending the in-memory manager by hand.
|
|
299
|
+
|
|
300
|
+
## Optional subsystems
|
|
301
|
+
|
|
302
|
+
The outline above is the mandatory base; everything else (localization, customer accounts, analytics + consent, search, subscriptions, B2B, content routes, SEO/sitemap, gift cards, redirects, recommendations, third-party integrations) is store-specific. Before touching optional features, read `references/optional-subsystems.md` — it gives the detection grep for each, the sequence that matters (**localization → auth → analytics+consent**, since later ones consume the earlier ones' output, then the lower-coupling features in any order), and the rule to **report every skip explicitly** so a missing subsystem never reads as "migrated."
|
|
303
|
+
|
|
304
|
+
## Traps by area — read the matching reference when you touch that area
|
|
305
|
+
|
|
306
|
+
Most "builds fine but silently broken at runtime" failures live in the `references/` files below, grouped by the surface they hit. Read the file for a surface **before** you migrate that surface, and skip the ones the store doesn't have — don't preload them all. Loading only the relevant slice leaves context for the app's own files, which is what one-shot success actually depends on.
|
|
307
|
+
|
|
308
|
+
| Surface you're migrating | Read |
|
|
309
|
+
| --- | --- |
|
|
310
|
+
| Dependencies, scripts, Vite config, `.gitignore` | `references/dependencies-and-tooling.md` |
|
|
311
|
+
| Routes, loaders, `@remix-run`→`react-router` sweep, `useMatches` handles | `references/routing-and-loaders.md` |
|
|
312
|
+
| TypeScript + gql (these surface at `tsc` / `hydrogen gql check`) | `references/typescript-and-gql-tada.md` |
|
|
313
|
+
| Data layer call-sites: `storefront.query`, `CacheLong()`, i18n variables, generated types | this page's mapping above + `references/typescript-and-gql-tada.md` + the `hydrogen-storefront-client` packaged skill |
|
|
314
|
+
| Cart drawer, `/cart` page, optimistic lines, `initialData` | `references/cart.md` |
|
|
315
|
+
| Customer accounts, OAuth, login | `references/customer-account.md` |
|
|
316
|
+
| Analytics + consent | `references/analytics.md` |
|
|
317
|
+
| Variant form, presentational primitives, pagination, a11y, dead client glue | `references/ui-and-components.md` |
|
|
318
|
+
| Sitemap / SEO | `references/sitemap-and-seo.md` |
|
|
319
|
+
|
|
320
|
+
Each file is a short list of the specific "it renders but is wrong" traps for that surface — the failures you can't discover by reading the installed package. For deploying to Oxygen, read the `hydrogen-oxygen` packaged skill; the seed's `--assets-dir`/`--worker-dir` flags are the template's layout, one of the layouts that skill accepts.
|
|
321
|
+
|
|
322
|
+
## Verification
|
|
323
|
+
|
|
324
|
+
Don't trust "it renders." Gate on the build first: `react-router build` and `react-router typegen && tsc && hydrogen gql check --fail-on-warn` must both exit 0 (most import/return-helper and gql traps surface here — fix the root cause, don't suppress it; a red build makes everything downstream meaningless). Then run the local `hydrogen-smoke-test` skill for functional/runtime verification — request handlers, product, cart, collection/search, analytics, money, and the production pass. That skill owns "does the storefront work"; don't restate its checks here.
|
|
325
|
+
|
|
326
|
+
After the build gate and smoke test pass, **keep the dev server running and load the home page in a browser.** Fix every console error and warning before handing off. If you have no browser tool, say so in the handoff and ask the user to check the console themselves. Then hand off to the user. Tell them to test the migrated code with `npm run dev` (or `npm run dev:https` when they need customer login locally), and to pay special attention to the areas a migration most often gets subtly wrong while still compiling:
|
|
327
|
+
|
|
328
|
+
- **CSP/nonce** — classic Hydrogen's `createContentSecurityPolicy`/`NonceProvider`/`useNonce` are gone; CSP is app-owned now, so port it as your own app code. A dropped nonce leaves pages rendering fine while hydration (cart, variant picker, analytics) is silently dead — check the browser console for CSP violations after migrating.
|
|
329
|
+
- **Analytics** — the commerce funnel (`view_item`/`add_to_cart`/…) silently dropping while page views still fire (`references/analytics.md`).
|
|
330
|
+
- **Cart actions** — add / remove / quantity edits and the drawer opening on add (`references/cart.md`).
|
|
331
|
+
- **Customer login** — the full OAuth round-trip, which needs an HTTPS origin locally (`references/customer-account.md`).
|
|
332
|
+
- **Product variant selection** — options must update client-side, not trigger a full route navigation (`references/ui-and-components.md`).
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Analytics traps
|
|
2
|
+
|
|
3
|
+
Read before migrating analytics + consent. Migrate the two together and after localization (so the i18n/currency inputs are settled before you wire the `shop` payload).
|
|
4
|
+
|
|
5
|
+
- **Port the analytics events — the bus still exists, only the classic React sugar is gone. Do not drop events.** Classic Hydrogen split analytics across `Analytics.Provider` (auto-published `page_viewed` + the cart events by diffing the `cart` prop), `<Analytics.ProductView/SearchView/CollectionView>` (published the route events), and, where the store had GTM or `dataLayer` glue, an app component that only *subscribed*. The 2026-10 library removes all of that (`Analytics.Provider`, the `<Analytics.*View>` components, `useAnalytics`, `getShopAnalytics` — and there is no `createStorefrontAnalytics` either). The current wiring: render `<ShopifyScripts>` (from `@shopify/hydrogen/react`) in the root head with `i18n`, `shop`, `consent`, `navigate`, and `routes` (your route templates) — it creates the bus and exposes it as the client-only singleton `window.Shopify.analytics`. Everything else is app code that talks to that singleton: publish route events (`AnalyticsEvent.PAGE_VIEWED`/`PRODUCT_VIEWED`/`COLLECTION_VIEWED`/`SEARCH_VIEWED`/`CART_VIEWED`) from tracker components on location change, and wire cart deltas with `useCartAnalytics()` (React) or `trackCartAnalytics(cartStore)` — never by hand-diffing the cart. The trap is that the easy migration deletes the classic `subscribe` calls and rewrites the tracking component to publish only page views off `useLocation` — the commerce funnel (`view_item`/`view_cart`/`add_to_cart`/`remove_from_cart`) silently disappears while the build stays green. **Follow the `hydrogen-analytics` packaged skill (`node_modules/@shopify/hydrogen/skills/hydrogen-analytics`) for the reference wiring** — it is exactly this port.
|
|
6
|
+
- **The bus needs the same-origin SFAPI proxy from `handleShopifyRoutes`.** Analytics posts through the app's own origin (wired by the `hydrogen-request-handlers` setup the outline already installs). Without it, the bus falls back to deprecated cookie-based tracking and checkout session continuity breaks — no build error, just degraded attribution.
|
|
7
|
+
- **Cart and client-only rules.** Cart tracking is client-only. `trackCartAnalytics(store)` throws when `window.Shopify.analytics` is missing, which includes SSR, so call it from a client-only effect. `useCartAnalytics()` already wraps that effect, so call the hook once from a component rendered inside `CartProvider` (the template's `CartAnalyticsTracker`) and never from inside another effect. Optimistic placeholder lines (see `references/cart.md`) lack the product fields Shopify analytics requires, so the template's `toAnalyticsCart` returns `null` for a cart without `id`/`updatedAt` and drops lines whose merchandise or product details are missing rather than publishing malformed events. Keep that filtering when you port it.
|
|
8
|
+
- **`shop` prop, channel, and `shopifyAnalytics`.** Build the `ShopifyScripts` `shop` prop yourself in the root loader (`getShopAnalytics` is gone). Its type is `ShopifyScriptsShop`: `{shopId, storefrontId, myshopifyDomain}`. `shopId` takes either the numeric `SHOP_ID` env var or the Shop GID from a Storefront API `shop { id }` query, and the bus normalizes both to a GID. The template queries it, so mock.shop works without env. `storefrontId` comes from `PUBLIC_STOREFRONT_ID`. Pass `""` when there is none (it also keeps PerfKit off), not the `"0"` the packaged `hydrogen-analytics` skill shows. The per-event `shop` payload (`ShopAnalytics`) is derived from that prop plus `channel`. `channel` defaults to `"hydrogen"`, which needs a real `storefrontId`. Without one, pass `channel: 'headless'`. `channel` and the optional `customData` go on the `analytics` prop. Do not push market country/language through the consent config. `shopifyAnalytics` and `webMcp` default to `true`, so a store without a `storefrontId` must pass `shopifyAnalytics={false}` or Shopify's analytics script loads with nothing to attribute to. The template's `resolveShopIdentity` (`app/lib/shop.ts`) derives all of this from env. Reduced to one block, the root renders:
|
|
9
|
+
|
|
10
|
+
```tsx
|
|
11
|
+
const storefrontId = env.PUBLIC_STOREFRONT_ID ?? "";
|
|
12
|
+
<ShopifyScripts
|
|
13
|
+
i18n={{ country, language, currency }}
|
|
14
|
+
shop={{ shopId, storefrontId, myshopifyDomain }}
|
|
15
|
+
analytics={{ channel: storefrontId ? "hydrogen" : "headless" }}
|
|
16
|
+
shopifyAnalytics={storefrontId !== ""}
|
|
17
|
+
consent={{ mode: "default-banner" }}
|
|
18
|
+
navigate={navigate}
|
|
19
|
+
routes={routeTemplates}
|
|
20
|
+
/>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The remaining props (`account`, `inbox`, `debug.standardEventsInspector`, `nonce`) are opt-in. Read the installed `ShopifyScriptsOptions` type before wiring them.
|
|
24
|
+
- **Every consumer is a destination, and consent gates delivery.** The bus is `publish`, `addDestination`, and `getConfig`. It has no `subscribe`, so there is no ungated observer. `publish` buffers up to 500 events. Destinations receive events live once Customer Privacy allows analytics, plus a replay of the buffer, and the bus clears the buffer if consent completes as denied. A store that had its own destination (GTM writing to `dataLayer`, say) keeps that mapping verbatim and registers it via `addDestination({name, setup: ({subscribe}) => …})`. `setup` only subscribes and maps. Load the GTM script tag elsewhere, because the `setup` contract says not to load third-party scripts or set cookies there. Nothing auto-publishes bus events. `initializeShopifyScripts` documents that it "sends a page view event", but that is the Standard Events DOM event for Shopify's own scripts, not a bus `page_viewed`. A bus with destinations but no `publish` calls tracks nothing.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Cart traps
|
|
2
|
+
|
|
3
|
+
Read before migrating the cart drawer, `/cart` page, or add-to-cart flow. These all present as "add to cart does nothing / cart is empty" while the mutation is actually fine — the render source or gating is wrong.
|
|
4
|
+
|
|
5
|
+
- **Cart lives at `/api/cart`; open the drawer off the cart store, not a fetcher.** `createCartServerHandlers()` owns `GET`/`POST /api/cart` with an `intent` field (`add`, …). But the client uses the library `useCartForm()` — `formProps()` submits through the library's own cart store (`store.handleFormSubmit` with `preventDefault`), so React Router's `useFetchers()` never sees the request. Any classic-Hydrogen "open the drawer when a `/api/cart` fetcher is in flight" logic silently stops firing: the mutation succeeds but the drawer never opens. Drive the drawer from the store via `useCart(...)` instead. And do **not** key off absolute `data.totalQuantity` — it jumps `0 → N` when an existing cart hydrates, so the drawer pops open on every page load. Watch a **mutation** signal: `useCart((s) => s.pending.lines.size > 0)` transitioning to true is the "an add is in flight" edge that fires only on user action (initial load leaves `pending` empty). The cart mutation and the drawer-open UI are separate concerns; verify both.
|
|
6
|
+
- **Render every cart surface from the store, not deferred loader data.** A classic-Hydrogen layout resolves the cart in the root loader and threads it into the drawer (and `/cart` page) through `<Suspense><Await resolve={cart}>`. In the 2026-10 library the cart is a client store, and the root loader usually no longer returns a `cart` field at all — so a carried-over `<Await resolve={cart}>` resolves to `undefined` and the drawer/page renders permanently empty *even though adds succeed and the header count updates* (the header reads the store; the drawer reads the dead promise). This looks like "add to cart does nothing" but the mutation is fine — only the render source is wrong. Point every cart surface at `useCart((s) => s.data)` and delete the deferred `cart` loader plumbing.
|
|
7
|
+
- **Gate line-item controls on optimistic identity, not `pending`.** The store already applies quantity edits optimistically and uses `pending.lines` only to reconcile/roll back. A scaffold that disables +/−/remove while `pending.lines.has(lineId)` locks the buttons for the entire `/api/cart` round-trip on every click — a visible "buffer" the classic optimistic cart never had. Disable only genuinely-new lines with no real cart-line id yet (their id carries the store's optimistic prefix, `optimistic:`); leave in-flight edits on real lines enabled so successive clicks stay instant.
|
|
8
|
+
- **Seed `CartProvider` `initialData` on the server or the cart flickers on first paint.** With no `initialData` the store starts empty and fetches `/api/cart` after hydration, so the cart page and drawer render their empty-state text first, then pop to the real cart. Fetch the cart in the root loader (`const result = await cartHandlers.get({storefrontClient, request})`) and pass `result.data` — the `{cart, errors?}` object, not the whole `result` (`{type, data, headers}`) and not `result.data.cart` — to `<CartProvider initialData={…}>`. **Await** it in the loader: a deferred promise leaves SSR empty and the flicker remains. Semantics: `{cart: null}` renders empty with no client retry, `undefined` triggers the post-hydration fetch.
|
|
9
|
+
- **`createCartServerHandlers` takes options — use them instead of wrapping.** `fragment` extends the cart query (extra line fields, metafields — see the `hydrogen-cart-metafields` packaged skill); `customerSession` syncs cart buyer identity with customer-account login state — with it set, `cartHandlers.get()` also requires `sessionManager` and `requestContext` in its context, and the account group needs `cartServerHandlers` (see `references/customer-account.md`, including the dev-mode caveat there). A store that wrapped the classic cart handler to add fields should move that into `fragment`, not re-wrap the handlers.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Customer account traps
|
|
2
|
+
|
|
3
|
+
Read before migrating login/logout, `/account*` routes, or any wrapper around the customer-account client.
|
|
4
|
+
|
|
5
|
+
- **Register the account routes as a handler group; link to them with plain `<a>`/`<form>`.** `createCustomerAccountServerHandlers(…)` (from `@shopify/hydrogen/customer-account`) returns a handler group — add it next to the cart group in the middleware: `handleShopifyRoutes({…, handlers: [cartHandlers, customerAccountHandlers]})`. The group owns `/account/login`, `/account/logout`, `/account/authorize`, and `/account/refresh` (constants `CUSTOMER_ACCOUNT_*_PATH`), so those requests must reach the server: use plain `<a href>` / `<form>` elements, **not** the router's `<Link>`/`<Form>`, which would client-side-navigate into a 404. Do not write route files for these paths. To keep cart buyer identity in sync with login state, create the cart handlers with the same session **and** hand them to the account group: `createCartServerHandlers({customerSession})` plus `createCustomerAccountServerHandlers({customerSession, cartServerHandlers: cartHandlers})`; with `customerSession` set, `cartHandlers.get(…)` also needs `sessionManager` and `requestContext` in its context (see `references/cart.md`). The 2026-10 template skips the sync (plain `createCartServerHandlers()`), which is a valid first migration step.
|
|
6
|
+
- **Don't hand-roll Customer Account OAuth.** Use the library: `createCustomerSession(...)` then `prepareLoginUrl` / `handleOAuthCallback` / `logout`. Hand-built authorize URLs get the path subtly wrong — the classic bug is an extra segment, `…/auth/oauth/authorize` instead of `…/oauth/authorize` — and Shopify returns a 4xx even though the page "loaded". The `client_id` is the raw `PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID` value verbatim (no `shp_` transform); getting the client_id "shape" wrong is a red herring — check the **path** first.
|
|
7
|
+
- **Customer login needs an HTTPS origin locally.** The library refuses to build an OAuth URL over `http` (`Customer Account OAuth origin must use HTTPS. Use a public HTTPS tunnel for local Customer Account login.`), so `/account/login` throws over plain `http://localhost` even when correctly wired — that is expected. The library sets no cookie of its own; the session cookie belongs to your `sessionManager`, and the template's `__Host-session` cookie additionally requires `Secure`. Use the library's `localHttps()` Vite plugin (from `@shopify/hydrogen/vite`): run `npm run dev:https` and open `https://local.tryhydrogen.dev:5173` (resolves to 127.0.0.1); it auto-provisions mkcert certificates, or run `npx hydrogen certs install` once up front (see the `hydrogen-local-https` packaged skill). Register the HTTPS origin's callback URL with the shop admin. Verify the *constructed* URL (path, `redirect_uri`, `scope`, PKCE params) separately from completing the round-trip.
|
|
8
|
+
- **If you wrap the customer-account client, type its `query`/`mutate` params as the document, not `string`.** gql.tada's `gql` from `@shopify/hydrogen/customer-account` returns a branded `CustomerAccountDocument` (a string at runtime). A wrapper that declares `query(q: string, …)` rejects every typed document. Type the param as `AnyCustomerAccountDocument` (exported from `@shopify/hydrogen/customer-account`); the underlying client already accepts it.
|
|
9
|
+
- **Customer addresses use `countryCode`, not `territoryCode`.** The library pins Customer Account API `2026-10`, which deprecates `CustomerAddress.territoryCode` in favour of `countryCode`, and the generated `CustomerAddressInput` has no `territoryCode` at all. A classic account-addresses route therefore fails the skill's own gate twice: `tsc` on any `territoryCode` input key, and `hydrogen gql check --fail-on-warn` on selecting the deprecated field. Select and send `countryCode` instead. It is the `CountryCode` enum: an upper-case two-letter code such as `US` (not `us`, `USA` or `840`), and an unknown value fails the whole request with a GraphQL validation error — so trim and upper-case what the shopper typed and reject anything that is not two letters before sending. 2026-10 also removes `Order.shippingTitle` and `Customer.lastIncompleteCheckout`; drop them from migrated account queries or `gql check` errors on them.
|
|
10
|
+
- **Current-build quirks — re-verify against the installed version before working around them.** In `2026.10.0`: (1) the package's `./customer-account` export has no `development` condition while `.` does, so under `vite dev` the core bundle (`dist/development/…`) and the customer-account bundle (`dist/customer-account/…`) are two module instances with different brand `Symbol`s. `createCustomerAccountServerHandlers({cartServerHandlers})` then throws `cartServerHandlers must be created by createCartServerHandlers with the customerSession option.` even when they were. Workaround: under `vite dev`, omit `cartServerHandlers` on the account group and `customerSession` on the cart handlers together (TypeScript rejects one without the other); a production build is unaffected because both entries then import the same `dist/core` module. Before assuming a later build fixed it, check `node_modules/@shopify/hydrogen/package.json` → `exports['./customer-account']` for a `development` key. (2) `shopify hydrogen env pull` may write a legacy `PUBLIC_CUSTOMER_ACCOUNT_API_URL` (`shopify.com/<shopId>` shape) — don't pass it as `customerAccountApiUrl` to `createCustomerSession`; omit the option and let the library derive the URL. (2) is unverified against the current CLI; test the direct wiring first.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Dependencies & tooling traps
|
|
2
|
+
|
|
3
|
+
Read before/while doing the dependency, script, Vite, and config steps of the migration outline. These are "install/build won't even start correctly" traps.
|
|
4
|
+
|
|
5
|
+
- **`@shopify/mini-oxygen` must be `^4.2.0`.** 4.2.0 is where the `oxygen()` plugin gained `configurePreviewServer` — below it, `vite preview` serves nothing. `@shopify/cli` must be ≥ `4.4.0`, where `shopify hydrogen deploy` gained the `--assets-dir`/`--worker-dir` flags the deploy script passes. Older majors of either "install fine" and fail at the exact step you use them.
|
|
6
|
+
- **`buildDirectory: 'dist'` in `react-router.config.ts` is load-bearing.** React Router's default build directory is `build/`, but the deploy script hard-codes `--assets-dir dist/client --worker-dir dist/server`. Omit the config value and the build "works" while deploy uploads nothing (or stale output). Set it explicitly and keep the flags and the config in step (the `hydrogen-oxygen` packaged skill describes the layouts `shopify hydrogen deploy` accepts).
|
|
7
|
+
- **`localHttps()` requires `enabled` — it does not self-detect the script name.** The plugin's options type requires `enabled: boolean`. Compute it in `vite.config`: `const enabled = process.env.npm_lifecycle_event === 'dev:https'`. Calling `localHttps()` bare (expecting it to read `npm_lifecycle_event` itself) fails typecheck or leaves HTTPS permanently off.
|
|
8
|
+
- **Use the env-var names from the 2026-10 template (`.env.example` and `app/lib/env.ts`).** Store: `PUBLIC_STORE_DOMAIN`, `PUBLIC_STOREFRONT_ID`, `PRIVATE_STOREFRONT_API_TOKEN`, optional `PUBLIC_STOREFRONT_API_TOKEN` (the public client uses the real store with or without it), optional `PUBLIC_CHECKOUT_DOMAIN`, and `MOCK_SHOP=1` to force the mock.shop demo. Customer accounts: `SHOP_ID` (the numeric shop id), `PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID`, `SESSION_SECRET` (signs the session cookie). A missing or empty `PRIVATE_STOREFRONT_API_TOKEN` must select the **public** client against the real store — never silently fall back to mock.shop, which is only for `MOCK_SHOP=1` or a store with no domain. mock.shop 401s the private-token header, and the client itself rejects an empty token string.
|
|
9
|
+
- **`vite` `ssr.optimizeDeps.include` must pre-bundle React + React Router.** In the workerd/SSR build, add `react`, `react/jsx-runtime`, `react/jsx-dev-runtime`, `react-dom`, `react-dom/server`, `react-router`, `react-router > cookie`, and `react-router > set-cookie-parser` to `ssr.optimizeDeps.include` (alongside whatever the app already lists). Omit these and dev/SSR fails with opaque module-interop errors that never point at the real cause.
|
|
10
|
+
- **Gitignore the generated typegen.** `.react-router/` (React Router `typegen` output) and `*-graphql-env.d.ts` (the gql schema environments, generated at the **project root**, not under `app/`) are regenerated on install/typecheck — add both to `.gitignore` so they don't land in the migration commit.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Optional subsystems: detect, then migrate or skip
|
|
2
|
+
|
|
3
|
+
Read this at the "Optional subsystems" step of the outline. The migration outline is the mandatory base. Most other functionality is store-specific — **detect it in the classic-Hydrogen source, migrate it with the matching step, otherwise skip it and say so.** Each check is a cheap grep against the old app; do not assume a store has (or lacks) a feature.
|
|
4
|
+
|
|
5
|
+
**Order matters — later subsystems consume earlier ones. Do them in this sequence:**
|
|
6
|
+
|
|
7
|
+
1. **Localization / i18n / multi-market** — detect: `@inContext`, a root `localization` query, `pathPrefix`, a country/language selector, `I18nBase`. Migrate first because it produces the currency + accepted-language that the analytics `shop` payload needs. Follow the `hydrogen-markets` packaged skill.
|
|
8
|
+
2. **Customer Accounts / auth** — detect: `createCustomerAccountClient`, `/account*` routes, login/logout, a `customerAccount` context. Migrate via the library (see the OAuth-path and HTTPS-origin traps in `references/customer-account.md`).
|
|
9
|
+
3. **Analytics + consent — one unit, do not split, and do it after localization.** Detect: `Analytics.Provider` / `useAnalytics` / `getShopAnalytics`, a `dataLayer` / GTM / GA snippet, *or* any consent / cookie-banner / customer-privacy config. Consent is a **load-bearing** input to `<ShopifyScripts consent={…}>`, not a sibling feature: Shopify Customer Privacy gates what reaches analytics **destinations** (every consumer is a destination, and destinations receive events only once Customer Privacy allows analytics), so wiring analytics without the consent config means destinations go dark while the build stays green. Migrate the two together, and after localization so the i18n inputs the `shop` payload needs are settled. See `references/analytics.md` for the port itself.
|
|
10
|
+
|
|
11
|
+
**Lower-coupling checks (migrate the ones the source has, any order, after the above):**
|
|
12
|
+
|
|
13
|
+
- **Search + predictive search** — detect: a `predictiveSearch` query or a `/search` route. The library ships the suite (`createPredictiveSearchServerHandlers` registered through `handleShopifyRoutes`, `createPredictiveSearchStore`, React `PredictiveSearchProvider`/`usePredictiveSearch*`) — follow the `hydrogen-predictive-search` packaged skill; don't port a hand-rolled predictive fetcher.
|
|
14
|
+
- **Shop Pay button** — detect: `<ShopPayButton>` or a Shop Pay checkout link. The library ships it again (`ShopPayButton` from `@shopify/hydrogen/react`, `createShopPayButton` for other frameworks) — follow the `hydrogen-shop-pay` packaged skill.
|
|
15
|
+
- **Subscriptions / selling plans** — detect: `sellingPlanGroups`, `sellingPlanAllocation`.
|
|
16
|
+
- **B2B** — detect: `buyerIdentity` company fields, `companyLocationId`, `buyer` context.
|
|
17
|
+
- **Content routes** — detect: `/blogs`, `/pages`, `/policies` routes and their `article`/`page`/`shopPolicy` queries.
|
|
18
|
+
- **SEO / sitemap / robots** — detect: a `seo` export, `sitemap.xml` / `robots.txt` routes, JSON-LD. (Sitemap helpers aren't in the library core — see `references/sitemap-and-seo.md`.)
|
|
19
|
+
- **Gift cards** — detect: `appliedGiftCards`, gift-card cart mutations.
|
|
20
|
+
- **URL redirects** — detect: a custom redirect map or `handleShopifyRedirects` usage.
|
|
21
|
+
- **Product recommendations** — detect: `productRecommendations` query.
|
|
22
|
+
|
|
23
|
+
Any custom third-party integration the store wrote itself (review widgets, marketing scripts, a page builder) is just app code — it migrates like any other component/route, with no Hydrogen-specific handling.
|
|
24
|
+
|
|
25
|
+
**Report every skip explicitly.** For each subsystem not detected, state "not present in source, skipped" in the migration summary. A silent omission reads identically to "migrated" and is exactly how a whole subsystem (analytics funnel, an account flow, sitemaps) goes missing unnoticed — the same no-silent-drop discipline the analytics and pagination traps in `references/` demand.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Routing & loader traps
|
|
2
|
+
|
|
3
|
+
Read while doing the routes & imports step. Misses here silently drop routes or leave sections that never render.
|
|
4
|
+
|
|
5
|
+
- **Import/return-helper sweep.** `@remix-run/react` → `react-router`, `@remix-run/node`/`server-runtime` → `react-router`; `json()`/`defer()` return values change. Miss one and typecheck usually catches it — run `react-router typegen && tsc --noEmit`.
|
|
6
|
+
- **Route file convention.** Preserve the existing flat route filenames (e.g. `($locale).*`) via `@react-router/fs-routes`; renaming during migration silently drops routes.
|
|
7
|
+
- **Deferred / streamed loader data.** Remix `defer()` + `<Await>` maps to RR7 patterns; a mis-migrated deferred loader can hang (page never reaches network-idle) so a whole section silently doesn't render (commonly a "featured" swimlane or a 404-page section).
|
|
8
|
+
- **RR7 `useMatches()[i].handle` is `unknown`.** Any classic-Hydrogen code reading a custom route `handle` (e.g. a per-route layout key) must cast it to the app's handle type before property access — otherwise `tsc` errors on the member. Cast the matched entry's `handle`, not just the `.find` predicate.
|
|
9
|
+
- **Handler-owned URLs are not page routes — delete or skip their route files.** `handleShopifyRoutes` (with the registered handler groups) owns: `/api/cart`, `/api/{version}/graphql.json`, the `/__shopify/*` proxy, `/api/mcp`, `/api/ucp/mcp`, `/.well-known/ucp`, `/graphiql` (dev), `/checkout`, cart permalinks (`/cart/{variantId}:{quantity}`), buy permalinks (`/buy/{itemId}:{quantity}`), AJAX cart URLs (`/cart.js`, `/cart/add.js`), Liquid-style `?variant=<id>` product redirects, and the customer-account paths (`/account/login|logout|authorize|refresh`). `/admin` redirects belong to `handleShopifyRedirects`, which runs only after a 404. A classic app often has route files for some of these (e.g. a `/cart` action route posting to itself is fine, but a carried-over `/api/cart`-style resource route shadows the handler). The `hydrogen-request-handlers` packaged skill holds the authoritative list and the full wiring.
|
|
10
|
+
- **Inside a try/catch or error boundary, `return await` the `handleShopifyRoutes` result.** The call itself is truthy-checked without `await` (it decides synchronously), but a bare `return shopifyRoute` inside a `try` lets a handler rejection escape the boundary — `return await shopifyRoute` keeps it catchable.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Sitemap & SEO traps
|
|
2
|
+
|
|
3
|
+
Read if the source has `sitemap.xml`/`robots.txt` routes or a `seo` export.
|
|
4
|
+
|
|
5
|
+
- **Sitemaps: `getSitemap`/`getSitemapIndex` are gone.** The 2026-10 library does not ship them, and the classic versions were typed for the classic `Storefront` client (`.query`) — the new request-scoped client only exposes `.graphql`, so they don't port. Write a small local `app/lib/sitemap.ts` that runs the sitemap queries via `storefront.graphql` and preserves any custom URL transforms (renamed routes, locale prefixes). The route files then call the local lib.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# TypeScript & gql traps
|
|
2
|
+
|
|
3
|
+
These surface when you run `react-router typegen && tsc` and `hydrogen gql check`. Read before wiring typed queries or re-pointing imports.
|
|
4
|
+
|
|
5
|
+
- **Typed GraphQL runs through the library's ts-plugin, not app-owned gql.tada config.** `@shopify/hydrogen` ships the schemas and a TypeScript plugin: add `{"name": "@shopify/hydrogen/ts-plugin"}` to `compilerOptions.plugins` in `tsconfig.json` and delete any classic-era gql.tada `schema`/`schemas` tsconfig block pointing at generated schema files. The plugin writes its schema environments (`*-graphql-env.d.ts`) at the **project root**; gitignore them. Add `hydrogen gql check --fail-on-warn` to the `typecheck` script — a wrong setup fails silently otherwise: typing just stops working, and nothing errors until you run the check. Type results with the `StorefrontApi` namespace / `ResultOf`; the library's `gql()` returns a **branded query string**, not a gql.tada document, so `FragmentOf` does **not** apply — to type a fragment's data, compose a throwaway query that spreads the fragment and take `ResultOf` of that (see the `hydrogen-storefront-client` packaged skill).
|
|
6
|
+
- **Framework types dropped from core are gone — but check the installed exports before shimming.** Types the app imported from `@shopify/hydrogen` in classic Hydrogen (e.g. `I18nBase`) may no longer be exported, so a bare type import fails with "no exported member". `I18nBase` → the library's `I18nConfig`. API object types → `@shopify/hydrogen/storefront-api-types` and `.../customer-account-api-types`. Several classic helpers **returned to core** (`flattenConnection`, `getSelectedProductOptions`, `formatMoney`, the `MoneyV2` type) — grep the installed package's `.d.mts` exports first. Only what is genuinely absent (component prop shapes, other helpers' types) gets a small local structural type for the fields the app actually uses; there is no classic package to fall back to.
|
|
7
|
+
- **gql result types are *partial and union-shaped*, unlike the old generated types.** Two distinct traps: (1) A query on a union/interface field (storefront `search`, metaobject `references`) is typed as the **full union** — the `types:`/`unavailableProducts:` argument and a single `...on X` inline fragment do **not** narrow it at the type level, so non-`X` members appear with only `__typename` and field access fails. Narrow with `Extract<…nodes[number], {__typename: 'X'}>` (a cast is honest here since the arg guarantees the runtime type). (2) A selection-derived type is **not** assignable to the full `storefront-api-types` type a component prop declares (e.g. `ProductVariant` requires `metafields` via `HasMetafields`, which the query didn't select). Either loosen the prop to the fields actually read (structural type) or bridge with a cast at the one boundary. Make `<Money>`/price components accept a structural `{amount, currencyCode}` and tolerate `null`/`undefined` — this also resolves the storefront-vs-customer-account `MoneyV2` **brand clash** that otherwise fails at every account money render.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# UI & component traps
|
|
2
|
+
|
|
3
|
+
Read while migrating the product variant form, product/collection lists, and re-owned root/layout. These render fine but are slow, a UX regression, or inaccessible.
|
|
4
|
+
|
|
5
|
+
- **Variant pickers are progressively-enhanced links, not `<button>`s and not plain navigation.** Two ways to get this wrong. Carried over verbatim, a classic form's `<Link>` per option value makes every variant change a full route navigation — laggy, selected state waits on the loader round-trip. But replacing the links with bare `<button>`s breaks the no-JS path and loses the crawlable URLs. The library's pattern (follow the `hydrogen-variant-form` packaged skill): render every same-product option value as a **real `<a href>` GET link**, then spread the product-form store's `register(…)` props on it so JS intercepts the click and `selectOption(name, value)` updates the selection client-side without navigating; the return value of `selectOption` tells you when a genuine navigation (different product `handle`) is still needed. A disabled `<button>` is only for values with `exists: false` (no variant combination at all). The store side is `createProductFormStore`/`createProductFormRegister` + `getSelectedVariant`; React apps get `createProductComponents()`/`useProductForm` from `@shopify/hydrogen/react`.
|
|
6
|
+
- **Some presentational primitives left core — but check before shimming.** `<Image>` and `<MediaFile>` component re-exports are no longer exported from `@shopify/hydrogen` — the `hydrogen-image` packaged skill gives the local reference implementation. `<Money>` needs no local shim anymore: core exports `formatMoney()` and the `hydrogen-money` packaged skill builds on it. Helpers such as `flattenConnection` and `getSelectedProductOptions` (and the `MoneyV2` type) are exported from core again — verify against the installed package's exports before writing a local copy. Typecheck flags the broken imports; resolve them up front rather than one failing route at a time.
|
|
7
|
+
- **List pagination: use the collection store, do not hand-roll.** Classic `<Pagination>` and `getPaginationVariables` are gone, but the library now ships a collection-browse suite: `createCollectionStore`/`createCollectionReconciler` plus filter/sort helpers (`parseCollectionParams`, `serializeCollectionParams`, `getFilterRemovalUrl`, `getSortByValue`) and React `CollectionProvider`/`useCollection*`. The `hydrogen-collection-browser` packaged skill gives the pattern: a GET-degradable browse form, and "Load more" as a real GET cursor link that JS upgrades to append-in-place. Hand-rolling accumulate + `IntersectionObserver` (or downgrading to bare Next/Previous links) is now a divergence from the library, not the faithful option.
|
|
8
|
+
- **Accessibility affordances you now own.** Classic-Hydrogen layouts often shipped a working skip-to-content link and route-change focus handling. After you re-own the root/layout, re-verify them: a skip link that is `sr-only` without a `focus:` reveal is permanently unusable; focus should move to the main landmark on navigation (give it `tabIndex={-1}`); and controls that were icon-only need an accessible name. Easy to carry over broken because it renders fine.
|
|
9
|
+
- **Prune classic-era client glue.** Swapping to the library primitives (`useCartForm`/`useCart`, the customer-account client, etc.) orphans the hand-rolled hooks and helpers that wrapped the old fetcher/route patterns. They keep compiling, so grep for now-unused ones and delete them rather than leaving dead code that implies a mechanism no longer in use.
|