@sixtyninecommerce/client 0.30.0 → 0.31.0

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.
Files changed (352) hide show
  1. package/README.md +548 -14
  2. package/dist/admin/http.d.ts.map +1 -1
  3. package/dist/admin/http.js.map +1 -1
  4. package/dist/admin/types.d.ts.map +1 -1
  5. package/dist/build-client.d.ts +9 -0
  6. package/dist/build-client.d.ts.map +1 -0
  7. package/dist/build-client.js +58 -0
  8. package/dist/build-client.js.map +1 -0
  9. package/dist/cache-tags.d.ts +35 -1
  10. package/dist/cache-tags.d.ts.map +1 -1
  11. package/dist/cache-tags.js +39 -1
  12. package/dist/cache-tags.js.map +1 -1
  13. package/dist/client.d.ts +25 -0
  14. package/dist/client.d.ts.map +1 -1
  15. package/dist/client.js +9 -62
  16. package/dist/client.js.map +1 -1
  17. package/dist/commerce/age-verification.d.ts +7 -4
  18. package/dist/commerce/age-verification.d.ts.map +1 -1
  19. package/dist/commerce/age-verification.js +6 -4
  20. package/dist/commerce/age-verification.js.map +1 -1
  21. package/dist/commerce/arguments.d.ts +13 -0
  22. package/dist/commerce/arguments.d.ts.map +1 -0
  23. package/dist/commerce/arguments.js +19 -0
  24. package/dist/commerce/arguments.js.map +1 -0
  25. package/dist/commerce/branding.d.ts.map +1 -1
  26. package/dist/commerce/branding.js.map +1 -1
  27. package/dist/commerce/capabilities.d.ts +40 -0
  28. package/dist/commerce/capabilities.d.ts.map +1 -0
  29. package/dist/commerce/capabilities.js +41 -0
  30. package/dist/commerce/capabilities.js.map +1 -0
  31. package/dist/commerce/cart.d.ts +9 -12
  32. package/dist/commerce/cart.d.ts.map +1 -1
  33. package/dist/commerce/cart.js +6 -1
  34. package/dist/commerce/cart.js.map +1 -1
  35. package/dist/commerce/checkout.d.ts +95 -15
  36. package/dist/commerce/checkout.d.ts.map +1 -1
  37. package/dist/commerce/checkout.js +230 -10
  38. package/dist/commerce/checkout.js.map +1 -1
  39. package/dist/commerce/customer.d.ts +82 -38
  40. package/dist/commerce/customer.d.ts.map +1 -1
  41. package/dist/commerce/customer.js +146 -10
  42. package/dist/commerce/customer.js.map +1 -1
  43. package/dist/commerce/favorites.d.ts +2 -5
  44. package/dist/commerce/favorites.d.ts.map +1 -1
  45. package/dist/commerce/favorites.js.map +1 -1
  46. package/dist/commerce/index.d.ts +189 -133
  47. package/dist/commerce/index.d.ts.map +1 -1
  48. package/dist/commerce/index.js +9 -0
  49. package/dist/commerce/index.js.map +1 -1
  50. package/dist/commerce/order-states.d.ts +18 -0
  51. package/dist/commerce/order-states.d.ts.map +1 -0
  52. package/dist/commerce/order-states.js +30 -0
  53. package/dist/commerce/order-states.js.map +1 -0
  54. package/dist/commerce/parcel-pricing.d.ts +48 -0
  55. package/dist/commerce/parcel-pricing.d.ts.map +1 -0
  56. package/dist/commerce/parcel-pricing.js +39 -0
  57. package/dist/commerce/parcel-pricing.js.map +1 -0
  58. package/dist/commerce/parked-orders.d.ts +22 -0
  59. package/dist/commerce/parked-orders.d.ts.map +1 -0
  60. package/dist/commerce/parked-orders.js +47 -0
  61. package/dist/commerce/parked-orders.js.map +1 -0
  62. package/dist/commerce/payments.d.ts +12 -5
  63. package/dist/commerce/payments.d.ts.map +1 -1
  64. package/dist/commerce/payments.js +10 -1
  65. package/dist/commerce/payments.js.map +1 -1
  66. package/dist/commerce/placement-error.d.ts +17 -0
  67. package/dist/commerce/placement-error.d.ts.map +1 -1
  68. package/dist/commerce/placement-error.js.map +1 -1
  69. package/dist/commerce/products.d.ts +92 -38
  70. package/dist/commerce/products.d.ts.map +1 -1
  71. package/dist/commerce/products.js +157 -123
  72. package/dist/commerce/products.js.map +1 -1
  73. package/dist/commerce/queries.d.ts +197 -48
  74. package/dist/commerce/queries.d.ts.map +1 -1
  75. package/dist/commerce/queries.js +1289 -746
  76. package/dist/commerce/queries.js.map +1 -1
  77. package/dist/commerce/quotes.d.ts +5 -4
  78. package/dist/commerce/quotes.d.ts.map +1 -1
  79. package/dist/commerce/quotes.js +1 -36
  80. package/dist/commerce/quotes.js.map +1 -1
  81. package/dist/commerce/recommendations.d.ts +3 -3
  82. package/dist/commerce/recommendations.d.ts.map +1 -1
  83. package/dist/commerce/recommendations.js +3 -1
  84. package/dist/commerce/recommendations.js.map +1 -1
  85. package/dist/commerce/returns.d.ts +7 -6
  86. package/dist/commerce/returns.d.ts.map +1 -1
  87. package/dist/commerce/returns.js.map +1 -1
  88. package/dist/commerce/reviews.d.ts +24 -15
  89. package/dist/commerce/reviews.d.ts.map +1 -1
  90. package/dist/commerce/reviews.js +67 -50
  91. package/dist/commerce/reviews.js.map +1 -1
  92. package/dist/commerce/saved-lists.d.ts +15 -15
  93. package/dist/commerce/saved-lists.d.ts.map +1 -1
  94. package/dist/commerce/saved-lists.js +67 -56
  95. package/dist/commerce/saved-lists.js.map +1 -1
  96. package/dist/commerce/search.d.ts +3 -2
  97. package/dist/commerce/search.d.ts.map +1 -1
  98. package/dist/commerce/search.js +2 -2
  99. package/dist/commerce/search.js.map +1 -1
  100. package/dist/commerce/shipping.d.ts +11 -4
  101. package/dist/commerce/shipping.d.ts.map +1 -1
  102. package/dist/commerce/shipping.js +17 -2
  103. package/dist/commerce/shipping.js.map +1 -1
  104. package/dist/commerce/stock-notifications.d.ts +2 -0
  105. package/dist/commerce/stock-notifications.d.ts.map +1 -1
  106. package/dist/commerce/stock-notifications.js +2 -2
  107. package/dist/commerce/stock-notifications.js.map +1 -1
  108. package/dist/commerce/subscriptions.d.ts +35 -37
  109. package/dist/commerce/subscriptions.d.ts.map +1 -1
  110. package/dist/commerce/subscriptions.js +159 -120
  111. package/dist/commerce/subscriptions.js.map +1 -1
  112. package/dist/commerce/vat-validation.d.ts +6 -3
  113. package/dist/commerce/vat-validation.d.ts.map +1 -1
  114. package/dist/commerce/vat-validation.js +24 -19
  115. package/dist/commerce/vat-validation.js.map +1 -1
  116. package/dist/constants.d.ts.map +1 -1
  117. package/dist/constants.js.map +1 -1
  118. package/dist/content/assets.d.ts +5 -4
  119. package/dist/content/assets.d.ts.map +1 -1
  120. package/dist/content/assets.js.map +1 -1
  121. package/dist/content/collection-pages.d.ts +3 -2
  122. package/dist/content/collection-pages.d.ts.map +1 -1
  123. package/dist/content/collection-pages.js.map +1 -1
  124. package/dist/content/collections.d.ts +5 -4
  125. package/dist/content/collections.d.ts.map +1 -1
  126. package/dist/content/collections.js.map +1 -1
  127. package/dist/content/filtered-products.d.ts.map +1 -1
  128. package/dist/content/filtered-products.js.map +1 -1
  129. package/dist/content/form-field-types.d.ts +3 -3
  130. package/dist/content/form-field-types.d.ts.map +1 -1
  131. package/dist/content/form-field-types.js.map +1 -1
  132. package/dist/content/forms.d.ts +4 -3
  133. package/dist/content/forms.d.ts.map +1 -1
  134. package/dist/content/forms.js +3 -0
  135. package/dist/content/forms.js.map +1 -1
  136. package/dist/content/index.d.ts +18 -17
  137. package/dist/content/index.d.ts.map +1 -1
  138. package/dist/content/items.d.ts +5 -4
  139. package/dist/content/items.d.ts.map +1 -1
  140. package/dist/content/items.js +2 -0
  141. package/dist/content/items.js.map +1 -1
  142. package/dist/content/menus.d.ts +3 -2
  143. package/dist/content/menus.d.ts.map +1 -1
  144. package/dist/content/menus.js +7 -1
  145. package/dist/content/menus.js.map +1 -1
  146. package/dist/content/resolve.d.ts +3 -2
  147. package/dist/content/resolve.d.ts.map +1 -1
  148. package/dist/content/resolve.js +1 -1
  149. package/dist/content/resolve.js.map +1 -1
  150. package/dist/content/sitemap.d.ts +3 -2
  151. package/dist/content/sitemap.d.ts.map +1 -1
  152. package/dist/content/sitemap.js.map +1 -1
  153. package/dist/content/strings.d.ts +16 -2
  154. package/dist/content/strings.d.ts.map +1 -1
  155. package/dist/content/strings.js +32 -4
  156. package/dist/content/strings.js.map +1 -1
  157. package/dist/core/context.d.ts +127 -1
  158. package/dist/core/context.d.ts.map +1 -1
  159. package/dist/core/context.js +436 -45
  160. package/dist/core/context.js.map +1 -1
  161. package/dist/core/currency.d.ts +14 -0
  162. package/dist/core/currency.d.ts.map +1 -0
  163. package/dist/core/currency.js +52 -0
  164. package/dist/core/currency.js.map +1 -0
  165. package/dist/core/deprecation.d.ts +23 -0
  166. package/dist/core/deprecation.d.ts.map +1 -0
  167. package/dist/core/deprecation.js +81 -0
  168. package/dist/core/deprecation.js.map +1 -0
  169. package/dist/core/http.d.ts +15 -1
  170. package/dist/core/http.d.ts.map +1 -1
  171. package/dist/core/http.js +55 -3
  172. package/dist/core/http.js.map +1 -1
  173. package/dist/core/public-artifact.d.ts +72 -0
  174. package/dist/core/public-artifact.d.ts.map +1 -0
  175. package/dist/core/public-artifact.js +122 -0
  176. package/dist/core/public-artifact.js.map +1 -0
  177. package/dist/core/unknown-field-rejection.d.ts +18 -0
  178. package/dist/core/unknown-field-rejection.d.ts.map +1 -0
  179. package/dist/core/unknown-field-rejection.js +37 -0
  180. package/dist/core/unknown-field-rejection.js.map +1 -0
  181. package/dist/credit/credit-status.d.ts +2 -0
  182. package/dist/credit/credit-status.d.ts.map +1 -1
  183. package/dist/credit/credit-status.js +71 -19
  184. package/dist/credit/credit-status.js.map +1 -1
  185. package/dist/credit/index.d.ts +1 -1
  186. package/dist/credit/payment-terms.d.ts +3 -3
  187. package/dist/credit/payment-terms.d.ts.map +1 -1
  188. package/dist/credit/payment-terms.js +28 -24
  189. package/dist/credit/payment-terms.js.map +1 -1
  190. package/dist/currency/exchange.d.ts +7 -7
  191. package/dist/currency/exchange.d.ts.map +1 -1
  192. package/dist/currency/exchange.js +5 -1
  193. package/dist/currency/exchange.js.map +1 -1
  194. package/dist/errors.d.ts +17 -0
  195. package/dist/errors.d.ts.map +1 -1
  196. package/dist/errors.js +13 -0
  197. package/dist/errors.js.map +1 -1
  198. package/dist/events/index.d.ts +20 -7
  199. package/dist/events/index.d.ts.map +1 -1
  200. package/dist/events/index.js +17 -5
  201. package/dist/events/index.js.map +1 -1
  202. package/dist/events/payloads.d.ts +7 -4
  203. package/dist/events/payloads.d.ts.map +1 -1
  204. package/dist/events/payloads.js +6 -3
  205. package/dist/events/payloads.js.map +1 -1
  206. package/dist/feeds/index.d.ts +5 -0
  207. package/dist/feeds/index.d.ts.map +1 -0
  208. package/dist/feeds/index.js +12 -0
  209. package/dist/feeds/index.js.map +1 -0
  210. package/dist/feeds/types.d.ts +18 -0
  211. package/dist/feeds/types.d.ts.map +1 -0
  212. package/dist/feeds/types.js +8 -0
  213. package/dist/feeds/types.js.map +1 -0
  214. package/dist/generated/cms.d.ts +2136 -0
  215. package/dist/generated/cms.d.ts.map +1 -0
  216. package/dist/generated/cms.js +4 -0
  217. package/dist/generated/cms.js.map +1 -0
  218. package/dist/generated/plugins.d.ts +550 -0
  219. package/dist/generated/plugins.d.ts.map +1 -0
  220. package/dist/generated/plugins.js +4 -0
  221. package/dist/generated/plugins.js.map +1 -0
  222. package/dist/generated/shop.d.ts +5539 -0
  223. package/dist/generated/shop.d.ts.map +1 -0
  224. package/dist/generated/shop.js +5 -0
  225. package/dist/generated/shop.js.map +1 -0
  226. package/dist/graphql-document.d.ts +29 -0
  227. package/dist/graphql-document.d.ts.map +1 -0
  228. package/dist/graphql-document.js +21 -0
  229. package/dist/graphql-document.js.map +1 -0
  230. package/dist/index.d.ts +9 -4
  231. package/dist/index.d.ts.map +1 -1
  232. package/dist/index.js +4 -2
  233. package/dist/index.js.map +1 -1
  234. package/dist/media/urls.d.ts.map +1 -1
  235. package/dist/media/urls.js +12 -1
  236. package/dist/media/urls.js.map +1 -1
  237. package/dist/media/video.d.ts.map +1 -1
  238. package/dist/media/video.js.map +1 -1
  239. package/dist/multi-store.d.ts +200 -0
  240. package/dist/multi-store.d.ts.map +1 -0
  241. package/dist/multi-store.js +558 -0
  242. package/dist/multi-store.js.map +1 -0
  243. package/dist/navigation/routes.d.ts +4 -3
  244. package/dist/navigation/routes.d.ts.map +1 -1
  245. package/dist/navigation/routes.js.map +1 -1
  246. package/dist/next/artifact-handler.d.ts +21 -13
  247. package/dist/next/artifact-handler.d.ts.map +1 -1
  248. package/dist/next/artifact-handler.js +28 -66
  249. package/dist/next/artifact-handler.js.map +1 -1
  250. package/dist/next/event-revalidate-handler.d.ts +26 -6
  251. package/dist/next/event-revalidate-handler.d.ts.map +1 -1
  252. package/dist/next/event-revalidate-handler.js +126 -19
  253. package/dist/next/event-revalidate-handler.js.map +1 -1
  254. package/dist/next/feed-handler.d.ts +50 -0
  255. package/dist/next/feed-handler.d.ts.map +1 -0
  256. package/dist/next/feed-handler.js +61 -0
  257. package/dist/next/feed-handler.js.map +1 -0
  258. package/dist/next/index.d.ts +3 -1
  259. package/dist/next/index.d.ts.map +1 -1
  260. package/dist/next/index.js +1 -0
  261. package/dist/next/index.js.map +1 -1
  262. package/dist/next/metadata.d.ts.map +1 -1
  263. package/dist/next/metadata.js.map +1 -1
  264. package/dist/next/redirect-middleware.d.ts +5 -31
  265. package/dist/next/redirect-middleware.d.ts.map +1 -1
  266. package/dist/next/redirect-middleware.js +4 -46
  267. package/dist/next/redirect-middleware.js.map +1 -1
  268. package/dist/next/revalidate-handler.d.ts.map +1 -1
  269. package/dist/next/revalidate-handler.js.map +1 -1
  270. package/dist/pricing/index.d.ts.map +1 -1
  271. package/dist/pricing/index.js.map +1 -1
  272. package/dist/pricing/tax-display.js +1 -1
  273. package/dist/pricing/tax-display.js.map +1 -1
  274. package/dist/pricing/tiers.d.ts +3 -2
  275. package/dist/pricing/tiers.d.ts.map +1 -1
  276. package/dist/pricing/tiers.js.map +1 -1
  277. package/dist/pricing/validation.d.ts +63 -12
  278. package/dist/pricing/validation.d.ts.map +1 -1
  279. package/dist/pricing/validation.js +60 -12
  280. package/dist/pricing/validation.js.map +1 -1
  281. package/dist/redirects/index.d.ts.map +1 -1
  282. package/dist/redirects/index.js +2 -1
  283. package/dist/redirects/index.js.map +1 -1
  284. package/dist/redirects/store.d.ts.map +1 -1
  285. package/dist/redirects/store.js +6 -2
  286. package/dist/redirects/store.js.map +1 -1
  287. package/dist/redirects/types.d.ts +5 -2
  288. package/dist/redirects/types.d.ts.map +1 -1
  289. package/dist/revalidation/event-plan.d.ts +55 -4
  290. package/dist/revalidation/event-plan.d.ts.map +1 -1
  291. package/dist/revalidation/event-plan.js +115 -8
  292. package/dist/revalidation/event-plan.js.map +1 -1
  293. package/dist/revalidation/event-verify.d.ts +8 -37
  294. package/dist/revalidation/event-verify.d.ts.map +1 -1
  295. package/dist/revalidation/event-verify.js +7 -97
  296. package/dist/revalidation/event-verify.js.map +1 -1
  297. package/dist/revalidation/types.d.ts.map +1 -1
  298. package/dist/revalidation/url-context.d.ts +17 -0
  299. package/dist/revalidation/url-context.d.ts.map +1 -1
  300. package/dist/revalidation/url-context.js +35 -2
  301. package/dist/revalidation/url-context.js.map +1 -1
  302. package/dist/seo/ai.d.ts.map +1 -1
  303. package/dist/seo/ai.js.map +1 -1
  304. package/dist/seo/artifacts.d.ts +86 -4
  305. package/dist/seo/artifacts.d.ts.map +1 -1
  306. package/dist/seo/artifacts.js +115 -16
  307. package/dist/seo/artifacts.js.map +1 -1
  308. package/dist/seo/availability.d.ts +2 -2
  309. package/dist/seo/availability.d.ts.map +1 -1
  310. package/dist/seo/availability.js.map +1 -1
  311. package/dist/seo/index.d.ts +2 -0
  312. package/dist/seo/index.d.ts.map +1 -1
  313. package/dist/seo/index.js +3 -1
  314. package/dist/seo/index.js.map +1 -1
  315. package/dist/seo/json-ld.d.ts.map +1 -1
  316. package/dist/seo/json-ld.js.map +1 -1
  317. package/dist/seo/meta-tags.d.ts.map +1 -1
  318. package/dist/seo/meta-tags.js.map +1 -1
  319. package/dist/seo/metadata-parser.d.ts.map +1 -1
  320. package/dist/seo/metadata-parser.js.map +1 -1
  321. package/dist/seo/metadata-types.d.ts.map +1 -1
  322. package/dist/seo/robots.d.ts.map +1 -1
  323. package/dist/seo/robots.js.map +1 -1
  324. package/dist/seo/types.d.ts +9 -2
  325. package/dist/seo/types.d.ts.map +1 -1
  326. package/dist/seo/url-builder.d.ts +5 -3
  327. package/dist/seo/url-builder.d.ts.map +1 -1
  328. package/dist/seo/url-builder.js +3 -1
  329. package/dist/seo/url-builder.js.map +1 -1
  330. package/dist/types.d.ts +1226 -2522
  331. package/dist/types.d.ts.map +1 -1
  332. package/dist/web/artifact-responder.d.ts +69 -0
  333. package/dist/web/artifact-responder.d.ts.map +1 -0
  334. package/dist/web/artifact-responder.js +90 -0
  335. package/dist/web/artifact-responder.js.map +1 -0
  336. package/dist/web/feed-responder.d.ts +72 -0
  337. package/dist/web/feed-responder.d.ts.map +1 -0
  338. package/dist/web/feed-responder.js +81 -0
  339. package/dist/web/feed-responder.js.map +1 -0
  340. package/dist/web/index.d.ts +23 -0
  341. package/dist/web/index.d.ts.map +1 -0
  342. package/dist/web/index.js +19 -0
  343. package/dist/web/index.js.map +1 -0
  344. package/dist/web/redirect-middleware.d.ts +76 -0
  345. package/dist/web/redirect-middleware.d.ts.map +1 -0
  346. package/dist/web/redirect-middleware.js +94 -0
  347. package/dist/web/redirect-middleware.js.map +1 -0
  348. package/dist/web/respond.d.ts +20 -0
  349. package/dist/web/respond.d.ts.map +1 -0
  350. package/dist/web/respond.js +52 -0
  351. package/dist/web/respond.js.map +1 -0
  352. package/package.json +18 -2
package/README.md CHANGED
@@ -125,7 +125,7 @@ const results = await client.commerce.searchProducts(
125
125
  // Pricing helpers
126
126
  client.pricing.formatPrice(2999, 'EUR'); // "€29.99"
127
127
  client.pricing.getTierForQuantity(variant.priceTiers, 10);
128
- client.pricing.validateOrderMinimums(order, { minOrderAmount: 5000 });
128
+ client.pricing.validateOrderMinimums(order, await client.commerce.getOrderConstraints());
129
129
 
130
130
  // Media helpers
131
131
  client.media.assetUrl('shop/source/photo.jpg');
@@ -135,11 +135,91 @@ client.media.getResponsiveImageData(asset); // { src, srcSet, sizes, alt }
135
135
  // Language & auth
136
136
  client.setLanguage('nl');
137
137
  client.setAuthToken(token);
138
+ ```
139
+
140
+ ## Several stores in one process
141
+
142
+ One storefront app can serve several stores. Pass `resolveStore` instead of `storeCode`, and you
143
+ get a **store router** back (not a promise). Each request asks it for a client of its own:
144
+
145
+ ```typescript
146
+ import { createStorefrontClient, storeFromHostMap } from '@sixtyninecommerce/client';
147
+
148
+ export const stores = createStorefrontClient({
149
+ cdnUrl: 'https://cdn.example.com',
150
+ // Your rule for which store a request belongs to. Return null for "none".
151
+ resolveStore: storeFromHostMap({
152
+ 'fashion.example.com': 'fashion-store',
153
+ 'trade.fashion.example.com': { storeCode: 'fashion-store', head: 'trade' },
154
+ 'gear.example.com': { storeCode: 'gear-store', language: 'nl' },
155
+ }),
156
+ stores: ['fashion-store', 'gear-store'], // optional allow-list
157
+ privateToken: { 'fashion-store': process.env.FASHION_TOKEN! }, // per store, server only
158
+ });
159
+
160
+ // Per request (a route handler, a server component, middleware):
161
+ const client = await stores.forRequest(request, {
162
+ customerToken: cookies.get('token'), // this shopper's session
163
+ currencyCode: cookies.get('currency'), // this shopper's currency
164
+ buyerIp: clientAddress, // sent only to a store with a private token
165
+ onAuthToken: (token) => cookies.set('token', token),
166
+ });
167
+ await client.commerce.getProduct('blue-shirt');
138
168
 
139
- // Escape hatch for custom queries
140
- const data = await client.graphql(`query { activeChannel { id } }`);
169
+ // Or name the store yourself:
170
+ const gear = await stores.forStore('gear-store');
141
171
  ```
142
172
 
173
+ - **Nothing a shopper does crosses requests.** The sign-in, language, currency, order scope, head
174
+ and buyer address belong to the client `forRequest`/`forStore` returned and to nothing else —
175
+ two concurrent requests, for one store or two, never send each other's. That is also why the
176
+ router's config refuses `customerToken`, `currencyCode` and `buyerIp`: pass them per request.
177
+ - **What a store shares** between its requests: the channel (read once, refreshed when the
178
+ platform's answer changes), its CDN, its redirect map and its private token. One channel lookup
179
+ serves every store.
180
+ - **Private tokens are per store.** A token is bound to one channel, and the platform ignores it on
181
+ any other store's requests, so `privateToken` is `{ [storeCode]: token }` or a function.
182
+ - **Which store** is your rule (`resolveStore(request) → code | { storeCode, head?, language? } |
183
+ null`). Two ready-made rules for a fixed list: `storeFromHostMap` (the `Host` header the server
184
+ received — exact, then on any port — and the URL only when there is none, since self-hosted
185
+ Next builds `request.url` on its own host; never `X-Forwarded-Host`, which a visitor can send)
186
+ and `storeFromPathPrefix`
187
+ (`{ '/nl': 'store-nl', '/be': 'store-be' }`, whole segments, longest prefix first). Host keys
188
+ may be written in Unicode or punycode (`bücher.example` = `xn--bcher-kva.example`). The URL you
189
+ pass must be absolute — on a plain `node:http` server, build it from the `Host` you trust.
190
+ - **Refusals** are `StorefrontError`s with status 404, so a page can render its not-found view from
191
+ `error.status`: `STORE_NOT_RESOLVED` (your rule returned null), `STORE_NOT_ALLOWED` (outside
192
+ `stores`), `CHANNEL_NOT_FOUND` (an unknown code, the bootstrap root, or an organization's
193
+ workspace — a hub is never a store). Set `stores` whenever your rule passes on anything taken
194
+ from the request, so a visitor cannot make the process look up codes it does not serve.
195
+ - **Canonical links** are always built on each store's main shop address (`storefrontUrl`),
196
+ whichever host or head the request came in on.
197
+ - **Cache tags** carry the store: every tag a read is sent with also goes out as
198
+ `store:<code>:<tag>` (`storeCacheTag`), so one store's events purge that store's pages only (see
199
+ [Next.js revalidation](#nextjs-revalidation-platform-events)). The `/next` and `/web` adapters
200
+ accept the router too: `createSeoArtifactHandler(stores, 'sitemap')` /
201
+ `createSeoArtifactResponder(stores, 'sitemap')`, `createProductFeedHandler(stores)` /
202
+ `createProductFeedResponder(stores)` and `createRedirectMiddleware({ client: stores, onError })`
203
+ serve each request from its own store, and answer a request no store serves with 404 (the
204
+ middleware falls through; its `onError` also hears when a store's redirect map fails to load).
205
+ The `/next` handlers tag their upstream fetch store-scoped too; `/web` sets no cache tags.
206
+ - **Per router, not per request:** a `conditionalReads: { maxEntries, maxBytes }` cache, and the
207
+ once-per-route deprecation warning, are kept for the router's whole life.
208
+
209
+ ### Heads (`head`)
210
+
211
+ A store can have several sites — a consumer shop and a trade portal, say — registered as heads.
212
+ `head` names the one a client speaks for, sent as `x-head`, and the platform composes every link it
213
+ emails for that request (sign-in, magic link, invite, password reset) on that head's address:
214
+
215
+ ```typescript
216
+ const client = await createStorefrontClient({ storeCode: 'fashion-store', cdnUrl, head: 'trade' });
217
+ ```
218
+
219
+ Any key the store registered (lowercase letters, digits, single hyphens); the platform honours a
220
+ `web`, `trade` or `native` head and ignores any other, falling back to the store's default head.
221
+ Unset, nothing is sent — exactly as before. Canonical links do not follow it.
222
+
143
223
  ## Deprecated: the `/admin` surface
144
224
 
145
225
  `@sixtyninecommerce/client/admin` (`createCommerceAdminClient`) is **deprecated**. Use
@@ -161,16 +241,77 @@ Five things have no successor yet, and are the reason to stay on this surface:
161
241
  This surface still works and is unchanged; it will be removed in a future major, not before those
162
242
  land.
163
243
 
244
+ ## Deprecated: `client.graphql()`
245
+
246
+ `client.graphql(query, variables)` — a raw Shop API document — is **deprecated**. It keeps
247
+ working until 1.0, and the first call in a process prints one line saying so. Every common use has
248
+ a typed method now:
249
+
250
+ | Instead of a raw query for | Call |
251
+ |---|---|
252
+ | a collection or product by id | `commerce.getCollectionById(id)`, `commerce.getProductById(id)` |
253
+ | storefront metafields | `commerce.getProduct(slug, { metafields: true })` (also `getProductById`, `getCollection`, `getCollectionById`) |
254
+ | the session currency | `createStorefrontClient({ currencyCode })`, `commerce.setOrderCurrency(code)` |
255
+ | what the store is ready to serve | `commerce.getCapabilities()` |
256
+ | one content string | `content.getContentString(id)` |
257
+ | logo, cover, favicon / a CMS asset | `commerce.getBranding()` / `content.getAsset(id)` |
258
+ | a named sign-in strategy, order custom fields, unsetting addresses, order states | `commerce.authenticate`, `setOrderCustomFields`, `unsetShippingAddress` / `unsetBillingAddress`, `getNextOrderStates` / `transitionOrder` |
259
+
260
+ The Shop API fields no method sends yet are listed in the MCP capability catalog
261
+ (`storefront://capability-catalog`). Route the notice to your logger, or silence it, with
262
+ `onDeprecation`:
263
+
264
+ ```typescript
265
+ const client = await createStorefrontClient({
266
+ storeCode: 'fashion-store',
267
+ cdnUrl: 'https://cdn.fashion-store.com',
268
+ // kind 'method' (client.graphql) or 'endpoint' (a platform route sent a Deprecation header)
269
+ onDeprecation: (notice) => logger.warn({ deprecation: notice }),
270
+ });
271
+ ```
272
+
273
+ Unset, a deprecated method is printed once per process, a deprecated platform route once per
274
+ client. Set, the hook is called once per client for each (once per router with `resolveStore`).
275
+
164
276
  ## Modules
165
277
 
166
278
  | Module | Description |
167
279
  |--------|-------------|
168
- | `commerce` | Products, collections, property trees, search, cart, checkout, customer auth |
169
- | `content` | CMS resolve, content items, assets, forms, content strings, menus |
280
+ | `commerce` | Products, collections, property trees, search, cart, checkout, customer auth, the store's capability manifest (`getCapabilities`) |
281
+ | `content` | CMS resolve, content items, assets, forms, content strings (all, or one by id), menus |
170
282
  | `navigation` | Unified route manifest, route lookup |
171
283
  | `pricing` | Price formatting, tier lookup, order validation (pure), resolved tax display mode |
172
284
  | `media` | CDN URLs, preset URLs, responsive image data (pure functions) |
173
285
  | `currency` | Exchange rates, currency conversion |
286
+ | `feeds` | Product-feed URLs (Google Shopping, Meta, Pinterest, TikTok) |
287
+
288
+ ## Registration
289
+
290
+ `register` creates the account and **signs nobody in**. The store emails the address a
291
+ "finish creating your account" link; the account works once the shopper opens it and chooses a
292
+ password there, which proves the address is theirs. Opening it signs them in. So after `register`
293
+ show "check your email", never a signed-in page, and do not call `login`: until the link is used,
294
+ a login answers `PASSWORD_RESET_REQUIRED` and emails the link again.
295
+
296
+ ```typescript
297
+ await client.commerce.register({ emailAddress, firstName, lastName });
298
+ // → { success: true } whether or not the address already has an account (no enumeration).
299
+ showMessage('Check your email to finish creating your account.');
300
+ ```
301
+
302
+ - **The link lands on the reset route you already serve**: `/reset-password?token=…` (a trade
303
+ account: `/b2b/login/reset?token=…`), finished with `resetPassword(token, password)`, which sets
304
+ the password, verifies the account and starts the session. A magic-link sign-in on the address
305
+ finishes it too. Nothing new to build.
306
+ - **A password sent to `register` is checked, then ignored.** It still answers
307
+ `PASSWORD_VALIDATION_ERROR` for one that breaks the length rule, so a form that keeps the field
308
+ behaves as before, but nothing stores it: drop the field and ask for the password on the reset
309
+ page.
310
+ - **A lost email recovers on its own**: registering again, "forgot password" and a sign-in attempt
311
+ each send the link again (rate-limited like a reset).
312
+ - **`NATIVE_AUTH_STRATEGY_ERROR`** from `register` means the store has no storefront address to
313
+ link the email to; its `message` says so. Nothing was created.
314
+ - The store sends its welcome email once the account is finished, not at registration.
174
315
 
175
316
  ## Passwordless (Magic Link) Login
176
317
 
@@ -180,9 +321,14 @@ with password login (`login`) — both are available. For unknown emails the ser
180
321
  creates a verified account on first sign-in (when the channel enables signup).
181
322
 
182
323
  ```typescript
183
- // 1. Request — always resolves (anti-enumeration). Show "check your inbox" regardless.
324
+ // 1. Request — resolves whether or not the account exists (anti-enumeration).
325
+ // Show "check your inbox" regardless. Throws only on a rate limit or a real error.
184
326
  await client.commerce.requestMagicLink(email);
185
327
 
328
+ // A B2B portal says where the shopper started, so the link opens the B2B page:
329
+ await client.commerce.requestMagicLink(email, { portal: 'B2B' }); // → /b2b/login/magic
330
+ // 'CONSUMER' → /sign-in/magic. Omitted: derived from the account (a B2B customer gets the B2B path).
331
+
186
332
  // 2a. Consume the LINK (token from the emailed URL's ?token= param)
187
333
  const user = await client.commerce.loginWithMagicLink(token);
188
334
 
@@ -191,8 +337,78 @@ const user = await client.commerce.loginWithMagicCode(email, code);
191
337
  // Either path starts a session and fires onAuthToken — no extra token handling needed.
192
338
  ```
193
339
 
340
+ `portal` picks the landing page only and grants nothing: B2B portal access is still checked when
341
+ the link or code is consumed.
342
+
194
343
  Errors thrown (via `MutationError.errorCode`): `MAGIC_LINK_INVALID_ERROR`,
195
- `MAGIC_LINK_EXPIRED_ERROR`, `MAGIC_LINK_LOCKED_ERROR` (too many wrong codes).
344
+ `MAGIC_LINK_EXPIRED_ERROR`, `MAGIC_LINK_LOCKED_ERROR` (too many wrong codes), and, from
345
+ `requestMagicLink`, `MAGIC_LINK_RATE_LIMITED_ERROR` with the wait on `error.retryAfterSeconds`.
346
+
347
+ ### Sign-in rate limits
348
+
349
+ Two limiters can refuse a sign-in call. Both put the wait in `error.retryAfterSeconds`, and neither
350
+ says whether the account exists, so the message is safe to show.
351
+
352
+ | Limiter | Calls | Thrown as |
353
+ |---|---|---|
354
+ | The store's magic-link limiter (per email, per address) | `requestMagicLink` | `MutationError`, `errorCode: 'MAGIC_LINK_RATE_LIMITED_ERROR'` |
355
+ | The platform's credential limiter (by default 5 a minute per email per store, plus a per-address budget) | `login`, `loginWithMagicCode`, `requestMagicLink`, `register`, `requestPasswordReset`, `refreshVerification` | `StorefrontError`, `statusCode: 429`, `code: 'RATE_LIMITED'`, `retryable: true` |
356
+
357
+ The credential limiter answers with HTTP 429 before the mutation runs, so `login` throws rather
358
+ than resolving to a `LoginResult`. The SDK never retries a mutation, so wait `retryAfterSeconds`
359
+ before letting the shopper try again:
360
+
361
+ ```typescript
362
+ import { StorefrontError } from '@sixtyninecommerce/client';
363
+
364
+ try {
365
+ await client.commerce.login(email, password);
366
+ } catch (error) {
367
+ const rateLimited =
368
+ error instanceof StorefrontError &&
369
+ (error.statusCode === 429 || error.errorCode === 'MAGIC_LINK_RATE_LIMITED_ERROR');
370
+ if (!rateLimited) throw error;
371
+ showError(`Too many attempts. Try again in ${error.retryAfterSeconds ?? 60} seconds.`);
372
+ }
373
+ ```
374
+
375
+ **Server-side storefronts.** When your server calls the Shop API, every shopper shares your
376
+ server's address, so they all spend one per-address budget. Create the client with the head's
377
+ private storefront token and the shopper's address, and each shopper is metered on their own:
378
+
379
+ ```typescript
380
+ // Per request, on the server only (the client refuses a private token in a browser).
381
+ const client = await createStorefrontClient({
382
+ storeCode: 'my-store',
383
+ cdnUrl: 'https://cdn.my-store.com',
384
+ privateToken: process.env.SIXTYNINE_PRIVATE_TOKEN, // s69p_…, issued with admin.heads.privateTokens.issue
385
+ buyerIp: shopperIp, // the address YOUR server saw the shopper connect from
386
+ });
387
+ ```
388
+
389
+ ### Setting a first password
390
+
391
+ A customer who signs in by link or code has no password, so `updatePassword(current, next)` cannot
392
+ serve them. `getCurrentCustomer()` returns `hasPassword`; when it is `false`, offer "Set password"
393
+ and call `setPassword`:
394
+
395
+ ```typescript
396
+ const me = await client.commerce.getCurrentCustomer();
397
+ if (me && !me.hasPassword) {
398
+ await client.commerce.setPassword(newPassword); // the account is emailed a notice
399
+ } else {
400
+ await client.commerce.updatePassword(currentPassword, newPassword);
401
+ }
402
+ ```
403
+
404
+ `setPassword` works only while the account has no password: afterwards it throws `MutationError`
405
+ `PASSWORD_ALREADY_SET_ERROR`. It also throws `PASSWORD_VALIDATION_ERROR` (the reason is in
406
+ `error.fields.validationErrorMessage`), `NATIVE_AUTH_STRATEGY_ERROR` (password sign-in is not
407
+ available for this account on this store) and `SET_PASSWORD_REQUIRES_EMAIL_SIGN_IN_ERROR` (the
408
+ session did not sign in to this store by email link or code, for example it signed in on another
409
+ store: have the customer sign in with a link or code again, or send them through
410
+ `requestPasswordReset`; `error.message` says so in words a shopper can read). The platform's
411
+ credential limiter meters it like `updatePassword`.
196
412
 
197
413
  ### Reference UI (storefront)
198
414
 
@@ -206,7 +422,17 @@ function MagicLinkRequest() {
206
422
 
207
423
  async function request(e: React.FormEvent) {
208
424
  e.preventDefault();
209
- await client.commerce.requestMagicLink(email.trim()); // never reveals existence
425
+ try {
426
+ // never reveals existence; a B2B portal passes { portal: 'B2B' }
427
+ await client.commerce.requestMagicLink(email.trim());
428
+ } catch (error) {
429
+ const rateLimited =
430
+ error instanceof StorefrontError &&
431
+ (error.statusCode === 429 || error.errorCode === 'MAGIC_LINK_RATE_LIMITED_ERROR');
432
+ if (!rateLimited) throw error;
433
+ /* show "too many requests — try again in {error.retryAfterSeconds}s" */
434
+ return;
435
+ }
210
436
  setStatus('sent'); // always advance — even if no account exists
211
437
  }
212
438
 
@@ -352,6 +578,70 @@ Two language modes are transparent to the storefront — the resolver picks the
352
578
 
353
579
  Visibility is gated server-side: `PUBLIC` (everyone), `AUTHENTICATED` (logged-in only), `CUSTOMER_GROUP` (allowlist of customer groups). Anonymous visitors only ever see PUBLIC docs.
354
580
 
581
+ ## Invoices and credit notes (customer account)
582
+
583
+ A signed-in customer reads the invoices and retail credit notes of their OWN orders, by order code
584
+ (a credit account's accounts-receivable credit notes are not listed yet).
585
+ Anything else — another customer's order, a guest checkout's, another store's — is refused
586
+ `FORBIDDEN`, indistinguishable from an order that does not exist.
587
+
588
+ ```typescript
589
+ const invoices = await client.commerce.getMyOrderInvoices(order.code);
590
+ const creditNotes = await client.commerce.getMyOrderCreditNotes(order.code);
591
+ // creditNotes[n]: { id, creditNoteNumber, issuedOn, totalWithTax, currencyCode,
592
+ // kind, reason, invoiceNumber, createdAt }
593
+
594
+ // Mint on the Download click, never earlier and never cached — e.g. a route handler
595
+ // behind the button:
596
+ const { url } = await client.commerce.getMyCreditNoteDownloadUrl(creditNoteId);
597
+ return Response.redirect(url, 303);
598
+ ```
599
+
600
+ | Field | Notes |
601
+ |---|---|
602
+ | `totalWithTax` | The credited amount, positive minor units; the PDF prints it negative |
603
+ | `kind` | `adjustment` (an over-charge, goods kept), `return` (a settled return), `cancellation` (the whole invoice, nothing replaces it), `correction` (the whole invoice, a corrected one replaces it) |
604
+ | `invoiceNumber` | The invoice the note credits. On a `cancellation` or `correction` that invoice is void and `getMyOrderInvoices` no longer lists it |
605
+ | `issuedOn` | ISO-8601 date |
606
+
607
+ Documents are stored privately: there is no permanent URL. `getMyInvoiceDownloadUrl` and
608
+ `getMyCreditNoteDownloadUrl` mint a signed link that works for five minutes (`expiresAt`) and
609
+ is a bearer link for that time, so hand it to the browser at once. A credit note whose PDF is not
610
+ stored yet answers `DOCUMENT_PDF_NOT_AVAILABLE` until the store draws it again: show the shopper
611
+ that the document is not available yet rather than an error.
612
+
613
+ ## Metafields (custom data from apps and the store)
614
+
615
+ Apps and the store can attach custom fields to products, variants and collections. The ones
616
+ declared `storefront: read` come back WITH the entity when you ask for them, in the same request:
617
+
618
+ ```typescript
619
+ const product = await client.commerce.getProduct('blue-shirt', { metafields: true });
620
+ product.metafields; // [{ namespace: 'acme-badges', key: 'badge', value: 'eco' }]
621
+ product.variants[0].metafields; // each variant's own
622
+
623
+ // One app's fields only (its slug is the namespace):
624
+ await client.commerce.getProduct('blue-shirt', { metafields: { namespace: 'acme-badges' } });
625
+ await client.commerce.getCollection('summer', { metafields: true }); // collection.metafields
626
+ ```
627
+
628
+ Also on `getProductById`, `getCollectionById`. Without the option the reads are unchanged and
629
+ carry no `metafields`. Fields marked `hidden`, and fields of an app that was uninstalled, never
630
+ reach the storefront, and a store sees only its own values on a product it shares with another.
631
+ `value` is whatever the field's type describes (a string, a number, money in minor units, …).
632
+
633
+ The option needs an engine that serves `metafields` on these entities; against an older engine
634
+ only a read that asks for them fails (a GraphQL validation error), and every other read keeps
635
+ working.
636
+
637
+ **Caching:** a metafield read is tagged like the plain read (`product:<id>`, `collection:<id>`,
638
+ `catalog`), but no platform event reports a metafield change on a product, variant or collection
639
+ yet. That includes a value an app writes, a field turned `hidden`, and an uninstalled app's
640
+ fields being withdrawn. So the event revalidation handler below never refreshes `metafields`
641
+ alone. A page cached by tag keeps the old values until the product itself changes or the cache
642
+ expires, while the engine already answers the new ones. Give metafield-bearing pages a time-based
643
+ revalidation if their custom fields must follow quickly.
644
+
355
645
  ## Next.js revalidation (platform events)
356
646
 
357
647
  Create an event subscription (Dashboard → Settings → Event Subscriptions) pointing at
@@ -377,8 +667,12 @@ pass a wrapper such as `(tag) => revalidateTag(tag, 'max')`.
377
667
 
378
668
  **Cache tags.** Product and collection reads carry `next: { tags }`, and the handler purges the
379
669
  same tags: `product:<id>` on `getProductById` / `getProductProperties`, `collection:<id>` on
380
- `getCollectionById`, `getProductsInCollectionById` and `searchProducts({ collectionId })`. Load
381
- pages by the id the routes manifest resolves:
670
+ `getCollectionById`, `getProductsInCollectionById` and `searchProducts({ collectionId })`, and
671
+ `catalog` on EVERY catalogue read (products, collections, search, facets, properties,
672
+ recommendations) — the tag
673
+ the facet and price-list events purge, since neither names the products it moves. `getMenu(id)`
674
+ carries `menu:<id>`, which `menu.changed` purges. Load pages by the id the routes manifest
675
+ resolves:
382
676
 
383
677
  ```typescript
384
678
  const route = await client.navigation.findRoute(path, locale);
@@ -393,17 +687,228 @@ if (route?.type === 'collection') {
393
687
 
394
688
  `findRoute` also returns `route.cacheTags` for any other read on the page (and
395
689
  `cacheTagsForRoute(entry)` covers `getRoutes()` entries). Raw slug reads (`getProduct(slug)`,
396
- `getCollection(slug)`, `getProductsInCollection(slug)`) stay untagged — pass
690
+ `getCollection(slug)`, `getProductsInCollection(slug)`) carry only `catalog`, no id tag — pass
397
691
  `{ cacheTags: route.cacheTags }` if you must use one.
398
692
 
693
+ What each event revalidates is `planRevalidation`'s mapping: `product.*` (including
694
+ `channel_assigned`/`channel_removed`) and `product_variant.*` → `product:<id>`; `collection.membership_changed` → `collection:<id>`; `facet.*` and
695
+ `price_list.changed` → `catalog`; `menu.changed` → `menu:<id>`; `head.changed` → `seo-artifact`
696
+ and a fresh URL context; `channel.renamed` → a fresh URL context only; `promotion.changed` and
697
+ `asset.changed` → nothing (promotions price orders, not catalogue reads; an asset edit also sends
698
+ `content.site_content_changed`, which revalidates the whole site); content, collection-tree,
699
+ URL-setting and SEO-path events → paths or the whole site. A plan with `invalidateUrlContext`
700
+ (URL settings, head and rename events) tells a pull-feed consumer to re-read its URL context. Any other type is answered 200
701
+ `skipped`.
702
+
703
+ **Several stores on one route.** Each store's subscription has its own signing secret: pass a
704
+ function from the store code to it, and the store router as `urls`. Each event is then planned for
705
+ its own store (the envelope's `channel_code`): paths are composed on that store's address and
706
+ handed to `toRoutePath(url, locale, store)` — map them onto your routes (e.g. a host rewritten to
707
+ `/[store]/…`) — and every tag is purged store-scoped, which is what the router's reads carry beside
708
+ the bare tags. A whole-site plan still revalidates the whole app.
709
+
710
+ ```typescript
711
+ export const POST = createEventRevalidateHandler({
712
+ secret: (storeCode) => secrets[storeCode], // undefined → 500 (unset, retried), like a missing secret
713
+ urls: stores, // the store router
714
+ toRoutePath: (url, _locale, store) => `/${store}${url.pathname}`,
715
+ revalidatePath,
716
+ revalidateTag,
717
+ });
718
+ ```
719
+
720
+ A pull-feed consumer asks `planRevalidation(envelope, urls, { storeScoped: true })` for the same
721
+ store-scoped tags. Only for a router's reads: a single-store client's reads carry bare tags alone.
722
+
399
723
  Opt out with `createStorefrontClient({ cacheTags: false })`, or pass a resolver
400
724
  `(tags, { operation }) => tags` to rewrite them. The SDK never sets a cache policy.
401
725
 
402
- `verifyEventDelivery`, `planRevalidation` and the payload types are exported from
403
- `@sixtyninecommerce/client/events` for pull-feed consumers and custom handlers.
726
+ `verifyEventDelivery`, `planRevalidation` and the payload type of EVERY platform event are exported
727
+ from `@sixtyninecommerce/client/events` for pull-feed consumers and custom handlers, with
728
+ `createEventHandler` — a verified, typed webhook endpoint for any event, beyond revalidation:
729
+
730
+ ```typescript
731
+ import { createEventHandler } from '@sixtyninecommerce/client/events';
732
+
733
+ export const POST = createEventHandler({
734
+ secret: process.env.EVENTS_SECRET!,
735
+ on: {
736
+ 'menu.changed': async (event) => { /* event.data is MenuChangedV1 */ },
737
+ },
738
+ });
739
+ ```
740
+
741
+ There is no `poll` or `stream` here: the pull feed and the live stream need an API token holding
742
+ `ReadPlatformEvents`, which a storefront must not hold — a server-side job that has one uses
743
+ `@sixtyninecommerce/admin`.
404
744
  `createRevalidateHandler` (the legacy `/api/revalidate` SEO webhook) is deprecated and unchanged,
405
745
  and receives nothing: the platform has retired its sender.
406
746
 
747
+ ## Conditional reads (ETag)
748
+
749
+ Public CMS and plugins reads — menus, content strings, collections, the routes and capability
750
+ manifests — are remembered and revalidated: the next read of the same thing sends `If-None-Match`,
751
+ and an unchanged answer comes back as a bodyless 304 that the SDK answers from memory. **On by
752
+ default, except on Next.js's patched `fetch`** (below). The server is still asked every time, so
753
+ nothing is ever served stale; what you save is the payload.
754
+
755
+ ```typescript
756
+ createStorefrontClient({ storeCode, cdnUrl }); // on (off on Next's fetch): 500 reads / 5 MB per process
757
+ createStorefrontClient({ storeCode, cdnUrl, conditionalReads: true }); // on, Next included
758
+ createStorefrontClient({ storeCode, cdnUrl, conditionalReads: { maxEntries: 100 } }); // a cache of this client's own
759
+ createStorefrontClient({ storeCode, cdnUrl, conditionalReads: false }); // off
760
+ ```
761
+
762
+ - **Never signed in.** Shop API calls (cart, checkout, account, every price) are never cached, and
763
+ the CMS/plugins reads never carry the shopper's session — they are the same request for a
764
+ signed-in shopper as for a guest.
765
+ - **Never across stores, languages or tokens.** The cache key is everything the request sends: the
766
+ store, the language, the private storefront token, the query.
767
+ - **Process-wide.** Every client on the same `fetch` shares one cache, so a client built per request
768
+ still benefits. Identical reads in flight at the same moment share one request, and each caller
769
+ gets its own copy of the answer. Errors are never stored.
770
+ - **Next.js: off by default.** On Next's patched `fetch` (it carries `__nextPatched`) the default
771
+ is `false`. The SDK cache sits above that `fetch`, and Next's data cache lives inside it: Next keys
772
+ a cached fetch on its request headers and stores only 200s, so an added `If-None-Match` would turn
773
+ Next's zero-request cache hits into 304 round trips. An app that does not cache its fetches with
774
+ Next (no `cache: 'force-cache'`, segment `revalidate` or `fetchCache`) can pass
775
+ `conditionalReads: true`. A wrapper around Next's `fetch` hides the marker: pass `false` then.
776
+
777
+ ## Product Feeds
778
+
779
+ The platform renders one RSS 2.0 product feed per configured destination (Google Shopping, Meta, Pinterest, TikTok) and serves it at a token-guarded public URL. `client.feeds.feedUrl(token)` builds that URL — the token is the feed's shared secret (Dashboard → Marketing → Product feeds), so treat the URL as unlisted.
780
+
781
+ ```typescript
782
+ client.feeds.feedUrl('9f3c…'); // https://plugins.example.com/feeds/megamart/9f3c….xml
783
+ ```
784
+
785
+ You can hand that platform URL to a merchant centre directly. To serve the feed from the storefront's **own** origin instead, proxy it with `createProductFeedHandler` — the same fetch-and-serve shape as `createSeoArtifactHandler`, so it works on any hosting adapter (unlike `next.config` rewrites):
786
+
787
+ ```typescript
788
+ // src/app/product-feed.xml/route.ts — one fixed feed
789
+ import { createProductFeedHandler } from '@sixtyninecommerce/client/next';
790
+
791
+ export const GET = createProductFeedHandler(getClient(), {
792
+ token: process.env.GOOGLE_FEED_TOKEN, // server-only
793
+ });
794
+
795
+ // src/app/feeds/[token]/route.ts — several feeds off one route
796
+ export const GET = createProductFeedHandler(getClient());
797
+ ```
798
+
799
+ | Behavior | Notes |
800
+ |---|---|
801
+ | Caching | `public, max-age=300, s-maxage=1800, stale-while-revalidate=86400` by default — override with `cacheControl` |
802
+ | `ETag` / `If-None-Match` | Forwarded upstream and passed back, so an unchanged feed answers `304` |
803
+ | Body | Buffered before the `200` goes out, so a timeout mid-transfer is a clean `503`, never a truncated feed a CDN would cache |
804
+ | User-Agent | The inbound crawler UA is **not** forwarded; the platform must see your server |
805
+ | `X-Robots-Tag` | Dropped from the upstream response |
806
+ | Failures | Upstream `404` → `404`; anything else (or a timeout, default 30s) → `503` with `Retry-After: 60` |
807
+
808
+ ## SEO artifacts, feeds and redirects on any framework (`/web`)
809
+
810
+ `@sixtyninecommerce/client/web` serves the platform-rendered sitemap, robots.txt, llms.txt,
811
+ IndexNow key and product feeds on the storefront's own origin, and applies the store's redirects,
812
+ from any server that speaks the Fetch API — `(request: Request) => Promise<Response>`, no framework
813
+ imports. The `/next` handlers are these same responders plus Next's cache tag, so the statuses,
814
+ headers and bodies are identical. Each takes a store router in place of a client too
815
+ ([several stores in one process](#several-stores-in-one-process)):
816
+
817
+ | Behavior | Notes |
818
+ |---|---|
819
+ | `ETag` / `If-None-Match` | Forwarded upstream and passed back; an unchanged file answers `304` with its `ETag` |
820
+ | Caching | The storefront's `Cache-Control` (`cacheControl` option); the platform's is replaced |
821
+ | `X-Robots-Tag` | Dropped from the upstream response |
822
+ | User-Agent | The inbound crawler UA is **not** forwarded; the platform must see your server |
823
+ | Credentials | None sent upstream — the files are public; a private storefront token never leaves the server |
824
+ | Child sitemaps | A name the platform could never serve (`<kind>-<n>.xml`) is a `404`, without a request |
825
+ | Failures | Upstream `404` → `404`; anything else (or the timeout: 15s, feeds 30s) → `503` + `Retry-After: 60` |
826
+ | `fetch` | The one you configured the client with (the `/next` handlers use Next's patched global) |
827
+
828
+ ```typescript
829
+ import {
830
+ createProductFeedResponder,
831
+ createRedirectMiddleware,
832
+ createSeoArtifactResponder,
833
+ } from '@sixtyninecommerce/client/web';
834
+
835
+ const client = getClient(); // a client or a client promise, built on the server
836
+ const sitemap = createSeoArtifactResponder(client, 'sitemap');
837
+ const sitemapChild = createSeoArtifactResponder(client, 'sitemap-child'); // (request, { name })
838
+ const robots = createSeoArtifactResponder(client, 'robots');
839
+ const llmsTxt = createSeoArtifactResponder(client, 'llms-txt');
840
+ const feed = createProductFeedResponder(client, { token: process.env.GOOGLE_FEED_TOKEN }); // server-only
841
+ const redirects = createRedirectMiddleware({ store: (await client).redirects }); // Response | undefined
842
+ ```
843
+
844
+ Plain `node:http`:
845
+
846
+ ```typescript
847
+ import { createServer } from 'node:http';
848
+
849
+ createServer(async (req, res) => {
850
+ const request = new Request(new URL(req.url ?? '/', `https://${req.headers.host}`), {
851
+ headers: req.headers as Record<string, string>,
852
+ });
853
+ const { pathname } = new URL(request.url);
854
+ const response =
855
+ (await redirects(request)) ??
856
+ (pathname === '/sitemap.xml' ? await sitemap(request)
857
+ : pathname.startsWith('/sitemaps/') ? await sitemapChild(request, { name: pathname.slice(10) })
858
+ : pathname === '/robots.txt' ? await robots(request)
859
+ : pathname === '/llms.txt' ? await llmsTxt(request)
860
+ : new Response('Not found', { status: 404 }));
861
+ res.writeHead(response.status, Object.fromEntries(response.headers));
862
+ res.end(Buffer.from(await response.arrayBuffer()));
863
+ }).listen(3000);
864
+ ```
865
+
866
+ Remix / React Router (resource routes):
867
+
868
+ ```typescript
869
+ // app/routes/sitemap[.]xml.ts
870
+ export const loader = ({ request }: LoaderFunctionArgs) => sitemap(request);
871
+ // app/routes/sitemaps.$name.ts
872
+ export const loader = ({ request, params }: LoaderFunctionArgs) => sitemapChild(request, params);
873
+ ```
874
+
875
+ Astro (endpoints):
876
+
877
+ ```typescript
878
+ // src/pages/robots.txt.ts
879
+ export const GET: APIRoute = ({ request }) => robots(request);
880
+ // src/pages/sitemaps/[name].ts
881
+ export const GET: APIRoute = ({ request, params }) => sitemapChild(request, params);
882
+ ```
883
+
884
+ SvelteKit (`+server.ts` and the `handle` hook):
885
+
886
+ ```typescript
887
+ // src/routes/llms.txt/+server.ts
888
+ export const GET: RequestHandler = ({ request }) => llmsTxt(request);
889
+ // src/hooks.server.ts
890
+ export const handle: Handle = async ({ event, resolve }) =>
891
+ (await redirects(event.request)) ?? resolve(event);
892
+ ```
893
+
894
+ Hono / Cloudflare Workers:
895
+
896
+ ```typescript
897
+ app.use(async (c, next) => (await redirects(c.req.raw)) ?? next());
898
+ app.get('/sitemap.xml', (c) => sitemap(c.req.raw));
899
+ app.get('/sitemaps/:name', (c) => sitemapChild(c.req.raw, { name: c.req.param('name') }));
900
+ app.get('/product-feed.xml', (c) => feed(c.req.raw));
901
+ ```
902
+
903
+ To read an artifact yourself, `client.seo.artifacts.fetch(artifact, { childFile?, ifNoneMatch?,
904
+ signal?, timeoutMs? })` answers `{ status: 200, body, contentType, etag }`, `{ status: 304, etag }`,
905
+ `{ status: 404 }` or `{ status: 503, retryAfterSeconds, error? }` — it never throws for an upstream
906
+ problem, and throws `INVALID_ARGUMENT` for an unknown artifact or a bad `childFile`.
907
+
908
+ There is no data cache in `/web`: your CDN caches per the `Cache-Control` above. When an event
909
+ plan from `planRevalidation` carries the `seo-artifact` tag (`head.changed`, and every plan that
910
+ revalidates anything), purge your CDN's copies of these paths.
911
+
407
912
  ## Tree-Shakeable Imports
408
913
 
409
914
  ```typescript
@@ -414,6 +919,26 @@ import { formatPrice, getTierForQuantity } from '@sixtyninecommerce/client/prici
414
919
  import { PRESETS, getResponsiveImageData } from '@sixtyninecommerce/client/media';
415
920
  ```
416
921
 
922
+ ## Generated types
923
+
924
+ The catalogue types are generated from the Shop API schema and the SDK's own queries, so they
925
+ describe exactly what each method returns: `ProductVariantFragment`, `ProductSummaryFragment`,
926
+ `CollectionDetailFragment`, `SearchResultFragment`, … A shape the SDK transforms keeps an SDK name
927
+ derived from the generated one (`Product` is `ProductFragment` with `metadata` parsed).
928
+
929
+ ```typescript
930
+ import type { Product, ProductVariantFragment } from '@sixtyninecommerce/client';
931
+
932
+ // Every generated type — operations, fragments, the schema's enums and inputs — type-only:
933
+ import type { CurrencyCode, GetProductQuery } from '@sixtyninecommerce/client/types';
934
+ ```
935
+
936
+ `getProducts` types its rows from the options: `ProductSummaryFragment` by default,
937
+ `ProductCardFragment` with `{ detail: 'card' }`.
938
+
939
+ Upgrading from 0.30: the rename table and every changed field are in
940
+ [docs/storefront/generated-types.md](https://github.com/SIXTYNINE-digital/sixtynine-commerce-engine/blob/main/docs/storefront/generated-types.md).
941
+
417
942
  ## Error Handling
418
943
 
419
944
  Every error this SDK throws is a `StorefrontError`, and every `StorefrontError` is a
@@ -439,7 +964,16 @@ try {
439
964
  // error.code / error.status — the platform code and HTTP status
440
965
  // error.statusCode — the same status, `undefined` when there was none
441
966
  // error.details — [{ field?, code, message }]
442
- // error.retryAfterSeconds — set on a 429
967
+ // error.retryable — true for a 429 and a 502/503/504 (an outage:
968
+ // code SERVICE_UNAVAILABLE when the engine's
969
+ // database is down); false for a 500 fault
970
+ // error.outcomeUnknown — true when the write may or may not have
971
+ // landed (never retryable): the engine said so,
972
+ // or the mutation timed out / was aborted by your
973
+ // signal after it was sent / lost its connection
974
+ // / got a 5xx nobody coded. Re-read the cart or
975
+ // order before trying again. Never set on a query.
976
+ // error.retryAfterSeconds — the wait hint, on a 429 or a 503
443
977
  // error.traceId — W3C trace id, on failures the service ANSWERED
444
978
  // (not on a timeout, abort or network failure)
445
979
  // error.problem — the RFC 9457 body, when the service sent one
@@ -1 +1 @@
1
- {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/admin/http.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,KAAK,WAAW,EAAsC,MAAM,cAAc,CAAC;AACpF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD;;;;;;GAMG;AAEH,MAAM,WAAW,eAAe;IAC9B,kDAAkD;IAClD,OAAO,EAAE,MAAM,CAAC;IAChB,+DAA+D;IAC/D,OAAO,EAAE,WAAW,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;CAChC;AAED,MAAM,WAAW,SAAS;IACxB,OAAO,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;CAChG;AAID,wBAAgB,eAAe,CAAC,MAAM,EAAE,eAAe,GAAG,SAAS,CA6ElE"}
1
+ {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/admin/http.ts"],"names":[],"mappings":"AAOA,OAAO,EAAE,KAAK,WAAW,EAAsC,MAAM,cAAc,CAAC;AACpF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAElD;;;;;;GAMG;AAGH,MAAM,WAAW,eAAe;IAC9B,kDAAkD;IAClD,OAAO,EAAE,MAAM,CAAC;IAChB,+DAA+D;IAC/D,OAAO,EAAE,WAAW,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;CAChC;AAGD,MAAM,WAAW,SAAS;IACxB,OAAO,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;CAChG;AAID,wBAAgB,eAAe,CAAC,MAAM,EAAE,eAAe,GAAG,SAAS,CA6ElE"}