@occluze/tether 0.6.8 → 0.7.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 (259) hide show
  1. package/README.md +11 -11
  2. package/dist/esm/astro.js +2 -2
  3. package/dist/esm/chunks/account-UIPWJQ3Y.js +1 -0
  4. package/dist/esm/chunks/account-pages-6ROA6Y4V.js +1 -0
  5. package/dist/esm/chunks/analytics-2V632IRV.js +1 -0
  6. package/dist/esm/chunks/chunk-4GCXM764.js +1 -0
  7. package/dist/esm/chunks/chunk-56FCLJJD.js +1 -0
  8. package/dist/esm/chunks/chunk-5Z5O3767.js +1 -0
  9. package/dist/esm/chunks/chunk-6OZJCAGZ.js +1 -0
  10. package/dist/esm/chunks/chunk-A5SYCVQM.js +1 -0
  11. package/dist/esm/chunks/chunk-BA4LQJJQ.js +1 -0
  12. package/dist/esm/chunks/chunk-BFTZR4OB.js +4 -0
  13. package/dist/esm/chunks/chunk-C5JYQCWH.js +1 -0
  14. package/dist/esm/chunks/chunk-E5YO6274.js +1 -0
  15. package/dist/esm/chunks/chunk-MPDWM2NN.js +1 -0
  16. package/dist/esm/chunks/chunk-T44MUL4Y.js +1 -0
  17. package/dist/esm/chunks/chunk-YLUULATY.js +1 -0
  18. package/dist/esm/chunks/diagnose-3FEKRIEK.js +14 -0
  19. package/dist/esm/chunks/extras-6POFBNOP.js +1 -0
  20. package/dist/esm/chunks/markets-ui-FD6NI2OG.js +1 -0
  21. package/dist/esm/chunks/search-UJ73YBQ5.js +1 -0
  22. package/dist/esm/index.js +1 -1
  23. package/dist/esm/server.js +2 -2
  24. package/dist/presets/barba.esm.js +19 -23
  25. package/dist/tether.account-pages.js +320 -365
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +242 -491
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +66 -88
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +36 -33
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +28 -36
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +1270 -894
  36. package/dist/tether.markets.js +73 -90
  37. package/dist/tether.markets.min.js +1 -1
  38. package/dist/tether.min.js +0 -0
  39. package/dist/tether.min.js.map +4 -4
  40. package/dist/tether.search.js +44 -68
  41. package/dist/tether.search.min.js +1 -1
  42. package/dist/types/server/src/shared/account-orders.d.ts +44 -0
  43. package/dist/types/server/src/shared/account-routes.d.ts +153 -0
  44. package/dist/types/server/src/shared/api-versions.d.ts +13 -0
  45. package/dist/types/server/src/shared/error-message.d.ts +2 -0
  46. package/dist/types/server/src/shared/json.d.ts +25 -0
  47. package/dist/types/server/src/shared/market-codes.d.ts +16 -0
  48. package/dist/types/src/account/addresses-view.d.ts +5 -0
  49. package/dist/types/src/account/answers.d.ts +40 -0
  50. package/dist/types/src/account/markup.d.ts +19 -0
  51. package/dist/types/src/account/operations.d.ts +41 -0
  52. package/dist/types/src/account/order-lines.d.ts +13 -0
  53. package/dist/types/src/account/order-list.d.ts +12 -0
  54. package/dist/types/src/account/order-page.d.ts +6 -0
  55. package/dist/types/src/account/page-answers.d.ts +182 -0
  56. package/dist/types/src/account/page-events.d.ts +2 -0
  57. package/dist/types/src/account/server-client.d.ts +60 -0
  58. package/dist/types/src/account/subscriptions-view.d.ts +2 -0
  59. package/dist/types/src/account-api.d.ts +18 -0
  60. package/dist/types/src/account-marks.d.ts +15 -0
  61. package/dist/types/src/account-pages.d.ts +8 -0
  62. package/dist/types/src/account.d.ts +64 -0
  63. package/dist/types/src/amounts.d.ts +12 -0
  64. package/dist/types/src/analytics-shopify.d.ts +41 -0
  65. package/dist/types/src/analytics.d.ts +2 -0
  66. package/dist/types/src/api-version.d.ts +28 -0
  67. package/dist/types/src/astro.d.ts +42 -0
  68. package/dist/types/src/cart/cart-shape.d.ts +43 -0
  69. package/dist/types/src/cart/checkout.d.ts +16 -0
  70. package/dist/types/src/cart/drawer.d.ts +18 -0
  71. package/dist/types/src/cart/mutations.d.ts +79 -0
  72. package/dist/types/src/cart/render.d.ts +17 -0
  73. package/dist/types/src/cart/store.d.ts +182 -0
  74. package/dist/types/src/cart.d.ts +106 -0
  75. package/dist/types/src/client.d.ts +100 -0
  76. package/dist/types/src/collection.d.ts +25 -0
  77. package/dist/types/src/config-sections.d.ts +40 -0
  78. package/dist/types/src/config.d.ts +185 -0
  79. package/dist/types/src/controls/cart-controls.d.ts +2 -0
  80. package/dist/types/src/controls/discount.d.ts +2 -0
  81. package/dist/types/src/controls/extras.d.ts +5 -0
  82. package/dist/types/src/controls/navigation.d.ts +12 -0
  83. package/dist/types/src/diagnose-markup.d.ts +19 -0
  84. package/dist/types/src/diagnose.d.ts +60 -0
  85. package/dist/types/src/dom.d.ts +75 -0
  86. package/dist/types/src/extras.d.ts +21 -0
  87. package/dist/types/src/fragments.d.ts +158 -0
  88. package/dist/types/src/globals.d.ts +15 -0
  89. package/dist/types/src/images.d.ts +20 -0
  90. package/dist/types/src/index.d.ts +167 -0
  91. package/dist/types/src/initial.d.ts +56 -0
  92. package/dist/types/src/lazy.d.ts +28 -0
  93. package/dist/types/src/loader.d.ts +84 -0
  94. package/dist/types/src/market-codes.d.ts +6 -0
  95. package/dist/types/src/markets-ui.d.ts +32 -0
  96. package/dist/types/src/markets.d.ts +72 -0
  97. package/dist/types/src/markup.d.ts +131 -0
  98. package/dist/types/src/mounting.d.ts +46 -0
  99. package/dist/types/src/page-market.d.ts +27 -0
  100. package/dist/types/src/presets/barba.d.ts +56 -0
  101. package/dist/types/src/product-events.d.ts +2 -0
  102. package/dist/types/src/product-render.d.ts +17 -0
  103. package/dist/types/src/product-shape.d.ts +88 -0
  104. package/dist/types/src/product-state.d.ts +110 -0
  105. package/dist/types/src/product.d.ts +66 -0
  106. package/dist/types/src/repeaters.d.ts +16 -0
  107. package/dist/types/src/search.d.ts +33 -0
  108. package/dist/types/src/selling-plans.d.ts +64 -0
  109. package/dist/types/src/server.d.ts +88 -0
  110. package/dist/types/src/shopify-values.d.ts +46 -0
  111. package/dist/types/src/status.d.ts +33 -0
  112. package/dist/types/src/storage.d.ts +12 -0
  113. package/dist/types/src/store-html.d.ts +39 -0
  114. package/dist/types/src/storefront/catalog.d.ts +38 -0
  115. package/dist/types/src/storefront/json-ld.d.ts +20 -0
  116. package/dist/types/src/variant-render.d.ts +14 -0
  117. package/package.json +24 -23
  118. package/src/account/addresses-view.ts +58 -0
  119. package/src/account/answers.ts +79 -0
  120. package/src/account/markup.ts +36 -0
  121. package/src/account/operations.ts +145 -0
  122. package/src/account/{order-lines.js → order-lines.ts} +20 -41
  123. package/src/account/order-list.ts +107 -0
  124. package/src/account/order-page.ts +137 -0
  125. package/src/account/page-answers.ts +317 -0
  126. package/src/account/{page-events.js → page-events.ts} +55 -90
  127. package/src/account/server-client.ts +183 -0
  128. package/src/account/{subscriptions-view.js → subscriptions-view.ts} +25 -68
  129. package/src/account-api.ts +98 -0
  130. package/src/account-marks.ts +35 -0
  131. package/src/{account-pages.js → account-pages.ts} +29 -37
  132. package/src/account.ts +284 -0
  133. package/src/amounts.ts +64 -0
  134. package/src/{analytics-shopify.js → analytics-shopify.ts} +112 -107
  135. package/src/analytics.ts +315 -0
  136. package/src/{api-version.js → api-version.ts} +25 -31
  137. package/src/{astro.js → astro.ts} +110 -104
  138. package/src/cart/cart-shape.ts +25 -0
  139. package/src/cart/checkout.ts +101 -0
  140. package/src/cart/{drawer.js → drawer.ts} +18 -31
  141. package/src/cart/mutations.ts +237 -0
  142. package/src/cart/{render.js → render.ts} +68 -96
  143. package/src/cart/store.ts +279 -0
  144. package/src/{cart.js → cart.ts} +82 -128
  145. package/src/client.ts +391 -0
  146. package/src/{collection.js → collection.ts} +30 -38
  147. package/src/config-sections.ts +284 -0
  148. package/src/config.ts +480 -0
  149. package/src/controls/{cart-controls.js → cart-controls.ts} +22 -54
  150. package/src/controls/{discount.js → discount.ts} +13 -34
  151. package/src/controls/{extras.js → extras.ts} +15 -24
  152. package/src/controls/{navigation.js → navigation.ts} +24 -30
  153. package/src/{diagnose-markup.js → diagnose-markup.ts} +37 -72
  154. package/src/{diagnose.js → diagnose.ts} +95 -96
  155. package/src/dom.ts +268 -0
  156. package/src/{extras.js → extras.ts} +43 -52
  157. package/src/fragments.ts +261 -0
  158. package/src/globals.d.ts +15 -0
  159. package/src/{images.js → images.ts} +30 -43
  160. package/src/index.ts +415 -0
  161. package/src/initial.ts +101 -0
  162. package/src/lazy-iife.ts +197 -0
  163. package/src/lazy.ts +42 -0
  164. package/src/{loader.js → loader.ts} +149 -123
  165. package/src/market-codes.ts +6 -0
  166. package/src/{markets-ui.js → markets-ui.ts} +91 -68
  167. package/src/markets.ts +246 -0
  168. package/src/markup.ts +127 -0
  169. package/src/{mounting.js → mounting.ts} +88 -101
  170. package/src/{page-market.js → page-market.ts} +32 -38
  171. package/src/part-integrity.ts +7 -0
  172. package/src/parts/account-pages.ts +7 -0
  173. package/src/parts/account.ts +7 -0
  174. package/src/parts/analytics.ts +7 -0
  175. package/src/parts/diagnose.ts +7 -0
  176. package/src/parts/extras.ts +7 -0
  177. package/src/parts/markets.ts +7 -0
  178. package/src/parts/search.ts +7 -0
  179. package/src/presets/barba.ts +194 -0
  180. package/src/{product-events.js → product-events.ts} +33 -48
  181. package/src/{product-render.js → product-render.ts} +38 -77
  182. package/src/product-shape.ts +49 -0
  183. package/src/product-state.ts +318 -0
  184. package/src/{product.js → product.ts} +90 -126
  185. package/src/{repeaters.js → repeaters.ts} +40 -52
  186. package/src/{search.js → search.ts} +85 -103
  187. package/src/selling-plans.ts +145 -0
  188. package/src/server.ts +211 -0
  189. package/src/{shopify-values.js → shopify-values.ts} +24 -22
  190. package/src/{status.js → status.ts} +34 -54
  191. package/src/storage.ts +34 -0
  192. package/src/store-html.ts +359 -0
  193. package/src/storefront/catalog.ts +196 -0
  194. package/src/storefront/{json-ld.js → json-ld.ts} +45 -68
  195. package/src/{variant-render.js → variant-render.ts} +24 -50
  196. package/dist/esm/chunks/account-C3NP6HFB.js +0 -1
  197. package/dist/esm/chunks/account-pages-WLK374FO.js +0 -1
  198. package/dist/esm/chunks/analytics-G3SAWMNL.js +0 -1
  199. package/dist/esm/chunks/chunk-27PJJZGC.js +0 -1
  200. package/dist/esm/chunks/chunk-2FSXS55U.js +0 -1
  201. package/dist/esm/chunks/chunk-FCSWQDVV.js +0 -1
  202. package/dist/esm/chunks/chunk-FOMSMIAM.js +0 -1
  203. package/dist/esm/chunks/chunk-GZJTCLLW.js +0 -1
  204. package/dist/esm/chunks/chunk-HEQILZVR.js +0 -1
  205. package/dist/esm/chunks/chunk-JOOEXYF3.js +0 -4
  206. package/dist/esm/chunks/chunk-MPTQYLSM.js +0 -1
  207. package/dist/esm/chunks/chunk-NEHZ37TU.js +0 -3
  208. package/dist/esm/chunks/chunk-V74KGEJF.js +0 -1
  209. package/dist/esm/chunks/chunk-VT4CDUJJ.js +0 -1
  210. package/dist/esm/chunks/chunk-YJLWX2HK.js +0 -1
  211. package/dist/esm/chunks/diagnose-6VVGZNTY.js +0 -14
  212. package/dist/esm/chunks/extras-GJVRDFM3.js +0 -1
  213. package/dist/esm/chunks/markets-ui-UNB4PTA7.js +0 -1
  214. package/dist/esm/chunks/search-OU75RNQL.js +0 -1
  215. package/src/account/addresses-view.js +0 -92
  216. package/src/account/browser-session.js +0 -359
  217. package/src/account/markup.js +0 -48
  218. package/src/account/operations.js +0 -180
  219. package/src/account/order-list.js +0 -91
  220. package/src/account/order-page.js +0 -192
  221. package/src/account/server-client.js +0 -172
  222. package/src/account/shared.js +0 -111
  223. package/src/account/shopify-auth.js +0 -150
  224. package/src/account-api.js +0 -118
  225. package/src/account-links.js +0 -35
  226. package/src/account.js +0 -342
  227. package/src/amounts.js +0 -36
  228. package/src/analytics.js +0 -352
  229. package/src/cart/checkout.js +0 -97
  230. package/src/cart/mutations.js +0 -234
  231. package/src/cart/store.js +0 -326
  232. package/src/client.js +0 -267
  233. package/src/config.js +0 -349
  234. package/src/dom.js +0 -287
  235. package/src/fragments.js +0 -237
  236. package/src/index.js +0 -353
  237. package/src/initial.js +0 -87
  238. package/src/lazy-iife.js +0 -116
  239. package/src/lazy.js +0 -39
  240. package/src/market-codes.js +0 -35
  241. package/src/markets.js +0 -334
  242. package/src/markup.js +0 -64
  243. package/src/parts/account-pages.js +0 -6
  244. package/src/parts/account.js +0 -6
  245. package/src/parts/analytics.js +0 -6
  246. package/src/parts/diagnose.js +0 -6
  247. package/src/parts/extras.js +0 -6
  248. package/src/parts/markets.js +0 -6
  249. package/src/parts/search.js +0 -6
  250. package/src/presets/barba.js +0 -190
  251. package/src/product-state.js +0 -318
  252. package/src/selling-plans.js +0 -154
  253. package/src/server.js +0 -215
  254. package/src/store-html.js +0 -0
  255. package/src/storefront/catalog.js +0 -199
  256. package/types/astro.d.ts +0 -28
  257. package/types/barba.d.ts +0 -16
  258. package/types/index.d.ts +0 -577
  259. package/types/server.d.ts +0 -182
package/src/client.ts ADDED
@@ -0,0 +1,391 @@
1
+ /**
2
+ * The one Storefront API client: a fetch wrapper that adds the market (`@inContext`), retries what Shopify asks to be
3
+ * retried, and turns every failure into a `TetherError`, an answer whose `data` lacks what its caller reads included
4
+ * (`DataCheck`). The browser (`gql`, and `query` for `Tether.query()`) and the server helpers (`request` with their own
5
+ * config) share it.
6
+ */
7
+ import { cfg, log } from './config.ts';
8
+ import { toCountry, toLanguage } from './market-codes.ts';
9
+ import { field, isRecord } from '../server/src/shared/json.ts';
10
+ import { messageOf } from '../server/src/shared/error-message.ts';
11
+ import type { TetherConfig } from './config.ts';
12
+ import type { CountryCode, LanguageCode } from './market-codes.ts';
13
+
14
+ export type TetherErrorCode =
15
+ | 'NOT_FOUND'
16
+ | 'NETWORK'
17
+ | 'NOT_CONFIGURED'
18
+ | 'NOT_LOGGED_IN'
19
+ | 'INVALID'
20
+ | 'INVALID_COUNTRY'
21
+ | 'INVALID_DISCOUNT'
22
+ | 'ERROR'
23
+ | `HTTP_${number}`;
24
+ export interface GraphQLError {
25
+ message: string;
26
+ extensions?: { code?: string };
27
+ }
28
+ /** The response shape the GraphQL spec sets. */
29
+ interface GraphQLResponse {
30
+ data?: unknown;
31
+ errors?: GraphQLError[];
32
+ }
33
+ export interface UserError {
34
+ message: string;
35
+ field?: string[] | null;
36
+ code?: string | null;
37
+ }
38
+ interface TetherErrorDetails {
39
+ /** Default `HTTP_<status>` with a status, else `ERROR`. */
40
+ code?: TetherErrorCode;
41
+ /** The HTTP status of the response that failed. */
42
+ status?: number;
43
+ /** The GraphQL errors of that response. */
44
+ errors?: GraphQLError[];
45
+ /** A mutation's user errors. */
46
+ userErrors?: readonly UserError[];
47
+ }
48
+
49
+ export class TetherError extends Error {
50
+ override name = 'TetherError' as const;
51
+ code: TetherErrorCode;
52
+ status: number | undefined;
53
+ errors: GraphQLError[] | undefined;
54
+ userErrors: readonly UserError[] | undefined;
55
+
56
+ constructor(message: string, { status, errors, userErrors, code }: TetherErrorDetails = {}) {
57
+ super(message);
58
+ this.code = code ?? (status ? `HTTP_${status}` : 'ERROR');
59
+ this.status = status;
60
+ this.errors = errors;
61
+ this.userErrors = userErrors;
62
+ }
63
+ }
64
+
65
+ /** What a request needs from a config: the browser's `cfg`, or a config the server helpers build. */
66
+ export type StorefrontConfig = Pick<
67
+ TetherConfig,
68
+ 'domain' | 'token' | 'apiVersion' | 'country' | 'language' | 'maxAge' | 'retries'
69
+ > & { fetch?: typeof fetch };
70
+ /** Checks a query's `data` before its caller reads it as a `T`. */
71
+ export type DataCheck<T> = (data: unknown) => data is T;
72
+ /**
73
+ * The structure of JSON a check accepts: a `typeof` name, `'object'` for a plain object, `[item]` for a list of
74
+ * items, an object's fields, or a check of its own. Fields it doesn't name aren't checked, so a shape names only what
75
+ * its reader walks without a fallback.
76
+ */
77
+ export type Shape =
78
+ | 'string'
79
+ | 'number'
80
+ | 'boolean'
81
+ | 'object'
82
+ | ((value: unknown) => boolean)
83
+ | readonly [Shape]
84
+ | { readonly [key: string]: Shape };
85
+
86
+ /** @returns True when `value` has the structure `shape` describes. */
87
+ export function matches(value: unknown, shape: Shape): boolean {
88
+ if (typeof shape === 'function') return shape(value);
89
+ if (typeof shape === 'string') return shape === 'object' ? isRecord(value) : typeof value === shape;
90
+ if (isShapeList(shape)) return Array.isArray(value) && value.every((item) => matches(item, shape[0]));
91
+ return isRecord(value) && Object.entries(shape).every(([key, inner]) => matches(value[key], inner));
92
+ }
93
+
94
+ /** `Array.isArray` doesn't narrow a readonly tuple out of a union. */
95
+ const isShapeList = (shape: Shape): shape is readonly [Shape] => Array.isArray(shape);
96
+
97
+ /** @returns The shape of `null` or a value with the structure `shape` describes. */
98
+ export const orNull =
99
+ (shape: Shape) =>
100
+ (value: unknown): boolean =>
101
+ value === null || matches(value, shape);
102
+
103
+ /** @returns A check that `data` has the structure `shape` describes, which `T` states. */
104
+ export const shaped =
105
+ <T>(shape: Shape): DataCheck<T> =>
106
+ (data): data is T =>
107
+ matches(data, shape);
108
+ /** `context: false` sends the query without `@inContext`. */
109
+ interface RequestOptions {
110
+ context?: boolean;
111
+ }
112
+ /** What the store's token may read beyond the defaults. */
113
+ export interface StoreAccess {
114
+ inventory: boolean;
115
+ }
116
+ /**
117
+ * A query's text, or a function that writes it for what the token may read: Tether's own queries, built from
118
+ * `fragments.ts`, leave out inventory for a token that can't read it.
119
+ */
120
+ export type QueryText = string | ((access: StoreAccess) => string);
121
+
122
+ /** @returns The Storefront GraphQL endpoint of `conf`'s store. */
123
+ export function endpoint(conf: Pick<TetherConfig, 'domain' | 'apiVersion'> = cfg): string {
124
+ return `https://${conf.domain}/api/${conf.apiVersion}/graphql.json`;
125
+ }
126
+
127
+ /** GraphQL strings and `#` comments: text in them is never part of an operation's header. */
128
+ const STRINGS_AND_COMMENTS = /"(?:\\.|[^"\\\n])*"|#[^\n]*/g;
129
+ /** An operation's keyword, name and variable definitions: named (query Name($x: T)) and anonymous (query { … }). */
130
+ const OPERATION_START = /^\s*(?:query|mutation)(?:\s+\w+)?\s*(?:\([^)]*\))?/;
131
+
132
+ /**
133
+ * Adds @inContext(country, language) to the first operation in the document, so prices, currency and translations
134
+ * follow the market. The header is found in a copy of the document whose strings and comments are blanked out, so a
135
+ * `)` in a default value or an `@inContext` in a comment or the selection set doesn't move or stop it; an operation
136
+ * that names its own `@inContext` keeps it. Tether's own documents start with their operation, and so do the ones
137
+ * `Tether.query()` is given in practice: a document that starts with a fragment is sent as written.
138
+ */
139
+ function inContext(
140
+ query: string,
141
+ { country, language }: { country: CountryCode | undefined; language: LanguageCode | undefined },
142
+ ): string {
143
+ const args: string[] = [];
144
+ if (country) args.push(`country: ${country}`);
145
+ if (language) args.push(`language: ${language}`);
146
+ if (!args.length) return query;
147
+ const tag = `@inContext(${args.join(', ')})`;
148
+ const blanked = query.replace(STRINGS_AND_COMMENTS, (text) => ' '.repeat(text.length));
149
+ const selectionStart = blanked.indexOf('{');
150
+ if (blanked.slice(0, selectionStart).includes('@inContext')) return query;
151
+ // Shorthand: a document that starts with its selection set is a query without a keyword.
152
+ if (blanked.trimStart().startsWith('{')) return `query ${tag} ${query.slice(selectionStart)}`;
153
+ const header = OPERATION_START.exec(blanked);
154
+ if (!header) return query;
155
+ const end = header[0].length;
156
+ return `${query.slice(0, end).trimEnd()} ${tag} ${query.slice(end).trimStart()}`;
157
+ }
158
+
159
+ /**
160
+ * `inContext` for any country and language, as the public `Tether.withContext` takes them: one that isn't a valid
161
+ * code is left out, so page input never becomes query text.
162
+ */
163
+ export function withContext(
164
+ query: string,
165
+ ctx: { country?: string | undefined; language?: string | undefined } = cfg,
166
+ ): string {
167
+ const country = toCountry(ctx.country) ?? undefined;
168
+ const language = toLanguage(ctx.language) ?? undefined;
169
+ return inContext(query, { country, language });
170
+ }
171
+
172
+ /** Backoff after a network error or a 429/5xx: this times 2^attempt. */
173
+ const RETRY_BACKOFF_MS = 300;
174
+ /** Backoff after Shopify's `THROTTLED` error, which asks for a longer wait: this times 2^attempt. */
175
+ const THROTTLE_BACKOFF_MS = 500;
176
+ /** Shopify's status for a request its bot protection rejected; retrying doesn't help. */
177
+ const SECURITY_REJECTION = 430;
178
+ const MS_PER_SECOND = 1000;
179
+
180
+ /**
181
+ * `quantityAvailable` needs the token's `unauthenticated_read_product_inventory` scope; without it Shopify refuses the
182
+ * whole query with an error naming the field. That refusal is learned once per page and store: Tether's later queries
183
+ * to the store are written without the field (`StoreAccess`), so stock levels are unknown and everything else works,
184
+ * for one extra request.
185
+ */
186
+ const INVENTORY_FIELD = 'quantityAvailable';
187
+
188
+ /** The stores (domain, version and token) whose token can't read inventory. */
189
+ const storesWithoutInventory = new Set<string>();
190
+
191
+ const storeOf = (conf: StorefrontConfig) => `${conf.domain} ${conf.apiVersion} ${conf.token}`;
192
+
193
+ /** @returns The text sent: with the market, and the cache's time bucket for a query. */
194
+ function queryText(conf: StorefrontConfig, query: QueryText, access: StoreAccess, context: boolean): string {
195
+ const written = typeof query === 'string' ? query : query(access);
196
+ const text = context ? inContext(written, conf) : written;
197
+ if (conf.maxAge <= 0 || /^\s*mutation\b/.test(text)) return text;
198
+ return `${text} #${Math.floor(Date.now() / (conf.maxAge * MS_PER_SECOND))}`;
199
+ }
200
+
201
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
202
+
203
+ /** What a status means, for errors Shopify sends without a message (401/403 have none). */
204
+ function statusText(status: number, conf: StorefrontConfig): string {
205
+ if (status === 401 || status === 403) {
206
+ const adminHint = conf.token.startsWith('shpat_') ? ', not an Admin API token (shpat_…)' : '';
207
+ return `Shopify rejected the Storefront token for ${conf.domain} (${status}): use the public Storefront API token (Headless channel)${adminHint}`;
208
+ }
209
+ if (status === 404) {
210
+ return `Store or API version not found (404): check domain ${conf.domain} and apiVersion ${conf.apiVersion}`;
211
+ }
212
+ return `HTTP ${status} from ${conf.domain}`;
213
+ }
214
+
215
+ /**
216
+ * @param value One entry of a response's `errors`.
217
+ * @returns The error, with an empty message when it has none.
218
+ */
219
+ function toGraphQLError(value: unknown): GraphQLError {
220
+ const message = field(value, 'message');
221
+ const code = field(field(value, 'extensions'), 'code');
222
+ const text = typeof message === 'string' ? message : '';
223
+ return typeof code === 'string' ? { message: text, extensions: { code } } : { message: text };
224
+ }
225
+
226
+ /**
227
+ * @param json A response body.
228
+ * @returns The body in the shape the GraphQL spec sets, `null` when it has another one.
229
+ */
230
+ function toGraphQLResponse(json: unknown): GraphQLResponse | null {
231
+ if (!isRecord(json)) return null;
232
+ const { data, errors } = json;
233
+ if (errors === undefined || errors === null) return { data };
234
+ if (!Array.isArray(errors)) return null;
235
+ return { data, errors: errors.map(toGraphQLError) };
236
+ }
237
+
238
+ /** For a body that isn't JSON, isn't in the GraphQL shape or has `data` its caller's check refuses. */
239
+ function invalidResponse(res: Response, conf: StorefrontConfig): TetherError {
240
+ const text = res.ok ? `Invalid response (${res.status})` : statusText(res.status, conf);
241
+ return new TetherError(text, { status: res.status });
242
+ }
243
+
244
+ function graphQLFailure(res: Response, errors: GraphQLError[], conf: StorefrontConfig): TetherError {
245
+ const message = errors
246
+ .map((error) => error.message)
247
+ .filter(Boolean)
248
+ .join(' | ');
249
+ let text = message || 'Storefront API error';
250
+ if (!res.ok) text = res.status === 401 || res.status === 403 || !message ? statusText(res.status, conf) : message;
251
+ return new TetherError(text, { status: res.status, errors });
252
+ }
253
+
254
+ type Attempt<T> =
255
+ { kind: 'data'; data: T } | { kind: 'retry'; backoffMs: number } | { kind: 'inventory-refused'; error: TetherError };
256
+
257
+ /**
258
+ * One request. Failures that retrying can't fix throw.
259
+ *
260
+ * @param canRetry False on the last attempt: a retryable failure then throws too.
261
+ */
262
+ async function attempt<T>(
263
+ conf: StorefrontConfig,
264
+ query: string,
265
+ variables: Record<string, unknown>,
266
+ isData: DataCheck<T>,
267
+ canRetry: boolean,
268
+ ): Promise<Attempt<T>> {
269
+ let res;
270
+ try {
271
+ res = await (conf.fetch ?? fetch)(endpoint(conf), {
272
+ method: 'POST',
273
+ headers: { 'Content-Type': 'application/json', 'X-Shopify-Storefront-Access-Token': conf.token },
274
+ body: JSON.stringify({ query, variables }),
275
+ });
276
+ } catch (error) {
277
+ if (canRetry) return { kind: 'retry', backoffMs: RETRY_BACKOFF_MS };
278
+ throw new TetherError(`Network error: ${messageOf(error)}`, {
279
+ code: 'NETWORK',
280
+ });
281
+ }
282
+ if (res.status === SECURITY_REJECTION) {
283
+ throw new TetherError('Shopify security rejection (430)', { status: SECURITY_REJECTION });
284
+ }
285
+ if (canRetry && (res.status === 429 || res.status >= 500)) {
286
+ return { kind: 'retry', backoffMs: RETRY_BACKOFF_MS };
287
+ }
288
+
289
+ let json: unknown;
290
+ try {
291
+ json = await res.json();
292
+ } catch {
293
+ throw invalidResponse(res, conf);
294
+ }
295
+ const body = toGraphQLResponse(json);
296
+ if (!body) throw invalidResponse(res, conf);
297
+ const { data, errors = [] } = body;
298
+ if (errors.length) {
299
+ if (query.includes(INVENTORY_FIELD) && errors.some((error) => error.message.includes(INVENTORY_FIELD))) {
300
+ return { kind: 'inventory-refused', error: graphQLFailure(res, errors, conf) };
301
+ }
302
+ if (canRetry && errors.some((error) => error.extensions?.code === 'THROTTLED')) {
303
+ return { kind: 'retry', backoffMs: THROTTLE_BACKOFF_MS };
304
+ }
305
+ throw graphQLFailure(res, errors, conf);
306
+ }
307
+ if (!res.ok) throw new TetherError(statusText(res.status, conf), { status: res.status });
308
+ if (!isData(data)) throw invalidResponse(res, conf);
309
+ return { kind: 'data', data };
310
+ }
311
+
312
+ /**
313
+ * One Storefront API request, for any config: the browser's shared one (`gql`) or one the server helpers build.
314
+ * Queries carry a time bucket so Shopify's cache of an identical query text is at most `maxAge` seconds old. A query
315
+ * written for the store's access leaves out inventory once the token turns out not to read it (`INVENTORY_FIELD`);
316
+ * a fixed query text is the caller's own, and that refusal is its error.
317
+ *
318
+ * @param isData Checks that `data` has what the caller then reads, so a malformed answer fails here.
319
+ * @throws {TetherError} `NOT_CONFIGURED` without a domain or token, `NETWORK` when the store can't be reached,
320
+ * `HTTP_<status>` for an HTTP or GraphQL failure, or for `data` that `isData` refuses.
321
+ */
322
+ export async function request<T>(
323
+ conf: StorefrontConfig,
324
+ query: QueryText,
325
+ variables: Record<string, unknown>,
326
+ isData: DataCheck<T>,
327
+ { context = true }: RequestOptions = {},
328
+ ): Promise<T> {
329
+ if (!conf.domain || !conf.token) {
330
+ throw new TetherError('Tether is not configured: domain and token are required', { code: 'NOT_CONFIGURED' });
331
+ }
332
+ const store = storeOf(conf);
333
+ let retries = 0;
334
+ for (;;) {
335
+ const sent = queryText(conf, query, { inventory: !storesWithoutInventory.has(store) }, context);
336
+ const outcome = await attempt(conf, sent, variables, isData, retries < conf.retries);
337
+ switch (outcome.kind) {
338
+ case 'data':
339
+ return outcome.data;
340
+ case 'inventory-refused':
341
+ if (typeof query === 'string') throw outcome.error;
342
+ log(`the token can't read inventory: ${INVENTORY_FIELD} is left out from now on`);
343
+ storesWithoutInventory.add(store);
344
+ break;
345
+ case 'retry':
346
+ retries += 1;
347
+ log('retry', retries);
348
+ await sleep(outcome.backoffMs * 2 ** retries);
349
+ break;
350
+ }
351
+ }
352
+ }
353
+
354
+ /** `request` with the browser's shared config. */
355
+ export function gql<T>(
356
+ query: QueryText,
357
+ variables: Record<string, unknown>,
358
+ isData: DataCheck<T>,
359
+ options?: RequestOptions,
360
+ ): Promise<T> {
361
+ return request(cfg, query, variables, isData, options);
362
+ }
363
+
364
+ /** The check of `Tether.query()` and the server's `query()`, whose callers read `data` themselves. */
365
+ export const hasData = (data: unknown): data is object => typeof data === 'object' && data !== null;
366
+
367
+ /** `Tether.query()`: the caller's own query text decides the shape of `data`, and the caller's `T` states it. */
368
+ export function query<T>(
369
+ text: QueryText,
370
+ variables: Record<string, unknown> = {},
371
+ options?: RequestOptions,
372
+ ): Promise<T> {
373
+ return gql(text, variables, hasData, options) as Promise<T>;
374
+ }
375
+
376
+ /**
377
+ * @param payload A mutation's payload.
378
+ * @param name The mutation, for the error message.
379
+ * @throws {TetherError} `ERROR` on an empty payload or on user errors (in `userErrors`).
380
+ */
381
+ export function unwrap<Payload extends { userErrors?: readonly UserError[] | null }>(
382
+ payload: Payload | null | undefined,
383
+ name: string,
384
+ ): Payload {
385
+ if (!payload) throw new TetherError(`${name}: empty response`);
386
+ const userErrors = payload.userErrors ?? [];
387
+ if (userErrors.length) {
388
+ throw new TetherError(userErrors.map((error) => error.message).join(' | '), { userErrors });
389
+ }
390
+ return payload;
391
+ }
@@ -3,8 +3,8 @@
3
3
  * first child of [tether-list] is the card template; every card becomes a product scope, so all product attributes
4
4
  * work inside cards (including quick-add with a subscription toggle).
5
5
  */
6
- import { reportError } from './config.js';
7
- import { TetherError } from './client.js';
6
+ import { reportError } from './config.ts';
7
+ import { TetherError } from './client.ts';
8
8
  import {
9
9
  attr,
10
10
  sel,
@@ -19,47 +19,42 @@ import {
19
19
  notifyLayoutChanged,
20
20
  busy,
21
21
  show,
22
- } from './dom.js';
23
- import { getProducts, getRecommendations } from './loader.js';
24
- import { setScopeStatus, isNotFound, resolveProductKey } from './status.js';
25
- /** @import { Product } from './fragments.js' */
26
- /** @import { Collection, PageInfo, ProductQuery } from './loader.js' */
22
+ } from './dom.ts';
23
+ import { getProducts, getRecommendations } from './loader.ts';
24
+ import { setScopeStatus, isNotFound, resolveProductKey } from './status.ts';
25
+ import type { Product } from './fragments.ts';
26
+ import type { Collection, PageInfo, ProductQuery } from './loader.ts';
27
27
 
28
28
  /** `tether-products="url:q"`: the search in the page's `?q=`, for a results page on a static site. */
29
29
  const URL_QUERY_SOURCE = 'url:';
30
30
 
31
31
  export const listSelector = () => `${sel('collection')},${sel('products')},${sel('recommendations')}`;
32
32
 
33
- /**
34
- * @param {EventTarget | null} target
35
- * @returns {target is Element}
36
- */
37
- const isElement = (target) => target instanceof window.Element;
33
+ const isElement = (target: EventTarget | null): target is Element => target instanceof window.Element;
38
34
 
39
35
  /**
40
- * @param {string | null} raw A `tether-limit`.
41
- * @returns {number | undefined} The limit; `undefined` for none, zero or not a number.
36
+ * @param raw A `tether-limit`.
37
+ * @returns The limit; `undefined` for none, zero or not a number.
42
38
  */
43
- const toLimit = (raw) => parseInt(raw ?? '', 10) || undefined;
39
+ const toLimit = (raw: string | null): number | undefined => parseInt(raw ?? '', 10) || undefined;
44
40
 
45
41
  /**
46
- * @param {string | null} raw A `tether-intent`.
47
- * @returns {'RELATED' | 'COMPLEMENTARY'} Shopify's recommendation intent, `RELATED` unless `COMPLEMENTARY` is written.
42
+ * @param raw A `tether-intent`.
43
+ * @returns Shopify's recommendation intent, `RELATED` unless `COMPLEMENTARY` is written.
48
44
  */
49
- const toIntent = (raw) => (raw === 'COMPLEMENTARY' ? 'COMPLEMENTARY' : 'RELATED');
45
+ const toIntent = (raw: string | null): 'RELATED' | 'COMPLEMENTARY' =>
46
+ raw === 'COMPLEMENTARY' ? 'COMPLEMENTARY' : 'RELATED';
50
47
 
51
48
  export class ListScope {
52
- /** @param {HTMLElement} root */
53
- constructor(root) {
49
+ readonly root: HTMLElement;
50
+ products: Product[] = [];
51
+ pageInfo: PageInfo | null = null;
52
+ collection: Collection | null = null;
53
+ /** Counts loads, so only the latest one renders. */
54
+ loadToken = 0;
55
+
56
+ constructor(root: HTMLElement) {
54
57
  this.root = root;
55
- /** @type {Product[]} */
56
- this.products = [];
57
- /** @type {PageInfo | null} */
58
- this.pageInfo = null;
59
- /** @type {Collection | null} */
60
- this.collection = null;
61
- /** Counts loads, so only the latest one renders. */
62
- this.loadToken = 0;
63
58
  setScopeStatus(root, 'loading');
64
59
  root.addEventListener('click', (event) => {
65
60
  if (!isElement(event.target)) return;
@@ -70,16 +65,14 @@ export class ListScope {
70
65
  });
71
66
  }
72
67
 
73
- /** @param {string} selector */
74
- $(selector) {
68
+ $(selector: string) {
75
69
  return scoped(this.root, selector, listSelector());
76
70
  }
77
71
 
78
- /** @returns {ProductQuery} The collection or search query of the list's attributes. */
79
- options() {
72
+ /** The collection or search query of the list's attributes. */
73
+ options(): ProductQuery {
80
74
  const { root } = this;
81
- /** @type {ProductQuery} */
82
- const query = { reverse: get(root, 'reverse') === 'true' };
75
+ const query: ProductQuery = { reverse: get(root, 'reverse') === 'true' };
83
76
  const first = toLimit(get(root, 'limit'));
84
77
  if (first) query.first = first;
85
78
  const sortKey = get(root, 'sort');
@@ -99,10 +92,9 @@ export class ListScope {
99
92
  * The product of a recommendations list: a handle, `url:…` (one dynamic product page), or empty inside a product
100
93
  * scope for that product.
101
94
  *
102
- * @returns {Promise<Product[]>}
103
95
  * @throws {TetherError} `NOT_FOUND` when there is no product to recommend for.
104
96
  */
105
- async loadRecommendations() {
97
+ async loadRecommendations(): Promise<Product[]> {
106
98
  let { key } = resolveProductKey(this.root, window.location, 'recommendations');
107
99
  const scope = this.root.closest(sel('product'));
108
100
  if (!key && scope) ({ key } = resolveProductKey(scope));
@@ -110,8 +102,8 @@ export class ListScope {
110
102
  return getRecommendations(key, toIntent(get(this.root, 'intent')));
111
103
  }
112
104
 
113
- /** @param {boolean} [more] Appends the next page instead of replacing the products. */
114
- async load(more = false) {
105
+ /** @param more Appends the next page instead of replacing the products. */
106
+ async load(more = false): Promise<void> {
115
107
  this.loadToken += 1;
116
108
  const token = this.loadToken;
117
109
  const stale = () => token !== this.loadToken;