@weaverse/hydrogen 5.23.0 → 6.0.0-beta.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 (81) hide show
  1. package/dist/index.js +29 -2271
  2. package/dist/index.mjs +4 -2213
  3. package/dist/internal.js +64 -0
  4. package/dist/internal.js.map +1 -0
  5. package/dist/internal.mjs +16 -0
  6. package/dist/internal.mjs.map +1 -0
  7. package/dist/next-server.js +14 -0
  8. package/dist/next-server.mjs +2 -0
  9. package/dist/next.js +119 -0
  10. package/dist/next.mjs +5 -0
  11. package/dist/react-router.js +68 -0
  12. package/dist/react-router.mjs +5 -0
  13. package/dist/react.js +26 -0
  14. package/dist/react.mjs +2 -0
  15. package/dist/translation-store-BpPWqZ0B.js +548 -0
  16. package/dist/translation-store-BpPWqZ0B.js.map +1 -0
  17. package/dist/translation-store-C42QdI0e.mjs +441 -0
  18. package/dist/translation-store-C42QdI0e.mjs.map +1 -0
  19. package/dist/types/WeaverseHydrogenRoot.d.ts +8 -8
  20. package/dist/types/WeaverseHydrogenRoot.d.ts.map +1 -1
  21. package/dist/types/components/index.d.ts +2 -2
  22. package/dist/types/components/main.d.ts +2 -2
  23. package/dist/types/hooks/translation-context.d.ts +2 -8
  24. package/dist/types/hooks/translation-context.d.ts.map +1 -1
  25. package/dist/types/index.d.ts +12 -33
  26. package/dist/types/index.d.ts.map +1 -1
  27. package/dist/types/internal.d.ts +24 -0
  28. package/dist/types/internal.d.ts.map +1 -0
  29. package/dist/types/next-server.d.ts +8 -0
  30. package/dist/types/next-server.d.ts.map +1 -0
  31. package/dist/types/next.d.ts +12 -0
  32. package/dist/types/next.d.ts.map +1 -0
  33. package/dist/types/react-router.d.ts +18 -0
  34. package/dist/types/react-router.d.ts.map +1 -0
  35. package/dist/types/react.d.ts +11 -0
  36. package/dist/types/react.d.ts.map +1 -0
  37. package/dist/types/seo.d.ts +4 -4
  38. package/dist/types/seo.d.ts.map +1 -1
  39. package/dist/types/types.d.ts +126 -67
  40. package/dist/types/types.d.ts.map +1 -1
  41. package/dist/types/utils/index.d.ts +4 -5
  42. package/dist/types/utils/index.d.ts.map +1 -1
  43. package/dist/types/utils/nested-key.d.ts +9 -0
  44. package/dist/types/utils/nested-key.d.ts.map +1 -0
  45. package/dist/types/utils/normalize-page-url.d.ts +1 -1
  46. package/dist/types/utils/product-options.d.ts +18 -0
  47. package/dist/types/utils/product-options.d.ts.map +1 -0
  48. package/dist/types/utils/translation-store.d.ts.map +1 -1
  49. package/dist/types/utils/use-studio.d.ts +1 -1
  50. package/dist/types/utils/use-studio.d.ts.map +1 -1
  51. package/dist/types/utils/use-theme-settings-store.d.ts +8 -8
  52. package/dist/types/utils/use-theme-settings-store.d.ts.map +1 -1
  53. package/dist/types/weaverse-client.d.ts +15 -16
  54. package/dist/types/weaverse-client.d.ts.map +1 -1
  55. package/dist/weaverse-client-Clu3u7md.mjs +1966 -0
  56. package/dist/weaverse-client-Clu3u7md.mjs.map +1 -0
  57. package/dist/weaverse-client-yC4TYuVV.js +2073 -0
  58. package/dist/weaverse-client-yC4TYuVV.js.map +1 -0
  59. package/package.json +44 -20
  60. package/readme.md +76 -23
  61. package/src/WeaverseHydrogenRoot.tsx +18 -15
  62. package/src/components/index.ts +2 -2
  63. package/src/components/main.tsx +2 -2
  64. package/src/hooks/translation-context.tsx +3 -28
  65. package/src/index.ts +42 -81
  66. package/src/internal.ts +64 -0
  67. package/src/next-server.ts +13 -0
  68. package/src/next.ts +35 -0
  69. package/src/react-router.ts +52 -0
  70. package/src/react.ts +16 -0
  71. package/src/seo.ts +5 -5
  72. package/src/types.ts +158 -73
  73. package/src/utils/index.ts +15 -14
  74. package/src/utils/nested-key.ts +28 -0
  75. package/src/utils/normalize-page-url.ts +1 -1
  76. package/src/utils/product-options.ts +28 -0
  77. package/src/utils/use-studio.ts +1 -1
  78. package/src/utils/use-theme-settings-store.ts +7 -7
  79. package/src/weaverse-client.ts +313 -148
  80. package/dist/index.js.map +0 -1
  81. package/dist/index.mjs.map +0 -1
@@ -1,32 +1,30 @@
1
- import {
2
- CacheCustom,
3
- createWithCache,
4
- type HydrogenEnv,
5
- type I18nBase,
6
- } from '@shopify/hydrogen'
1
+ import { Cache, createFetchWithCache } from '@shopify/hydrogen'
7
2
  import type {
8
3
  CachingStrategy,
9
4
  CustomPageEntry,
10
5
  FetchProjectPayload,
11
6
  FetchProjectRequestBody,
12
- HydrogenComponent,
13
- HydrogenComponentData,
14
- HydrogenPageData,
15
7
  LoadPageParams,
16
8
  PageType,
17
9
  ProjectIdValidationResult,
18
- ThemeSettingsResponse,
19
10
  WeaverseClientArgs,
11
+ WeaverseComponent,
12
+ WeaverseComponentData,
13
+ WeaverseEnv,
14
+ WeaverseI18n,
20
15
  WeaverseLoaderData,
16
+ WeaversePageData,
21
17
  WeaverseProjectConfigs,
22
18
  WeaverseStudioQueries,
19
+ WeaverseThemeSettingsResponse,
23
20
  WithCacheFetchResponse,
24
21
  } from './types'
25
22
  import {
26
23
  hasError,
27
- isFetchProjectPayload,
28
24
  isProjectIdFunction,
29
25
  WeaverseError,
26
+ WeaverseLoadError,
27
+ type WeaverseLoadFailureKind,
30
28
  } from './types'
31
29
  import {
32
30
  generateDataFromSchema,
@@ -41,14 +39,10 @@ import { normalizePageUrl } from './utils/normalize-page-url'
41
39
  * These values optimize CDN hit rates while maintaining fresh content.
42
40
  */
43
41
  const CACHE_DURATIONS = {
44
- /** Short browser freshness for content that can change in Builder */
42
+ /** Freshness for content that can change in Builder */
45
43
  SHORT: 60,
46
- /** Shared cache freshness for anonymous storefront traffic */
47
- SHARED_SHORT: 300,
48
44
  /** Long stale window so slow Builder responses do not block storefront rendering */
49
45
  LONG: 86_400,
50
- /** Extra freshness for sitemap-like index data */
51
- STATIC: 3600,
52
46
  } as const
53
47
 
54
48
  /**
@@ -76,27 +70,160 @@ function isRetryableFetchError(error: unknown): boolean {
76
70
  if (!(error instanceof WeaverseError)) {
77
71
  return false
78
72
  }
79
- if (error.code === 'TIMEOUT_ERROR' || error.code === 'NETWORK_ERROR') {
73
+ // A successful status with an unparsable body was classified as a network
74
+ // error before it got its own code; it keeps the same single retry.
75
+ if (
76
+ error.code === 'TIMEOUT_ERROR' ||
77
+ error.code === 'NETWORK_ERROR' ||
78
+ error.code === 'INVALID_RESPONSE'
79
+ ) {
80
80
  return true
81
81
  }
82
82
  return error.code === 'FETCH_ERROR' && (error.statusCode ?? 0) >= 500
83
83
  }
84
84
 
85
+ /** Parses a JSON body, or returns the raw text when it is not JSON. */
86
+ function parseResponseText(text: string): unknown {
87
+ if (!text) {
88
+ return text
89
+ }
90
+ try {
91
+ return JSON.parse(text)
92
+ } catch {
93
+ return text
94
+ }
95
+ }
96
+
97
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
98
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
99
+ }
100
+
101
+ /**
102
+ * Whether a successful `/project` body is a page envelope.
103
+ *
104
+ * `page: null` is Builder's authoritative "nothing assigned at this URL" and
105
+ * stays valid; anything without a `page` field, with an `error` field, or with
106
+ * a page that has no item list is not an answer about this URL at all.
107
+ */
108
+ function isProjectEnvelope(payload: unknown): boolean {
109
+ if (!isPlainObject(payload) || hasError(payload) || !('page' in payload)) {
110
+ return false
111
+ }
112
+ const { page, project } = payload
113
+ const validPage =
114
+ page === null || (isPlainObject(page) && Array.isArray(page.items))
115
+ return validPage && (project == null || isPlainObject(project))
116
+ }
117
+
118
+ /**
119
+ * Whether a successful `/project_configs` body carries theme settings.
120
+ *
121
+ * Builder always sends the `theme` field; `null` means the merchant never
122
+ * saved settings, so schema defaults are the real configuration.
123
+ */
124
+ function isThemeConfigsEnvelope(payload: unknown): boolean {
125
+ return (
126
+ isPlainObject(payload) &&
127
+ !hasError(payload) &&
128
+ 'theme' in payload &&
129
+ (payload.theme === null || isPlainObject(payload.theme))
130
+ )
131
+ }
132
+
133
+ /**
134
+ * Classifies a page-load failure. `'absent'` is Builder's 404 for a project
135
+ * that does not exist, which keeps resolving to `null` like before.
136
+ */
137
+ function classifyPageLoadFailure(
138
+ error: unknown
139
+ ): WeaverseLoadFailureKind | 'absent' {
140
+ if (error instanceof WeaverseLoadError) {
141
+ return error.kind
142
+ }
143
+ if (!(error instanceof WeaverseError)) {
144
+ // Thrown while turning a response into page data: the body was not
145
+ // the shape it claimed to be.
146
+ return 'invalid-response'
147
+ }
148
+ if (error.code === 'TIMEOUT_ERROR' || error.code === 'NETWORK_ERROR') {
149
+ return 'unavailable'
150
+ }
151
+ if (error.code === 'INVALID_RESPONSE') {
152
+ return 'invalid-response'
153
+ }
154
+ if (error.code === 'FETCH_ERROR') {
155
+ const status = error.statusCode ?? 0
156
+ if (status === 404) {
157
+ return 'absent'
158
+ }
159
+ if (status === 408 || status === 425 || status === 429 || status >= 500) {
160
+ return 'unavailable'
161
+ }
162
+ }
163
+ // Refusals (400/401/403) and project-ID errors: retrying cannot succeed.
164
+ return 'configuration'
165
+ }
166
+
167
+ /**
168
+ * Validators for Builder requests made by this client itself, keyed on the
169
+ * exact options object (same reasoning as `bodyFreeCacheRequests`). On the
170
+ * cached path such a request rejects a non-2xx status instead of resolving
171
+ * to `null`, and only stores a body its validator accepts. Every other
172
+ * `fetchWithCache` caller keeps the existing behavior.
173
+ */
174
+ const builderPayloadValidators = new WeakMap<
175
+ object,
176
+ (payload: unknown) => boolean
177
+ >()
178
+
179
+ /**
180
+ * Gives a rejection from Hydrogen's cached fetch the code `fetchOnce` gives
181
+ * the same failure on the direct path.
182
+ *
183
+ * Hydrogen calls the platform `fetch` itself, so a request that never got a
184
+ * response rejects with the platform's error instead of a `WeaverseError`.
185
+ * The Fetch standard rejects a network failure with a `TypeError` and an
186
+ * aborted request with the signal's `AbortError`/`TimeoutError`; those are
187
+ * outages. Anything else is returned unchanged, so it is not reported as one.
188
+ */
189
+ function cachedTransportError(error: unknown, url: string): unknown {
190
+ if (error instanceof WeaverseError || !(error instanceof Error)) {
191
+ return error
192
+ }
193
+ if (error.name === 'AbortError' || error.name === 'TimeoutError') {
194
+ return new WeaverseError('Request timed out', 'TIMEOUT_ERROR', undefined, {
195
+ url,
196
+ })
197
+ }
198
+ if (error instanceof TypeError) {
199
+ return new WeaverseError(
200
+ 'Network error during fetch',
201
+ 'NETWORK_ERROR',
202
+ undefined,
203
+ { url, cause: error.message }
204
+ )
205
+ }
206
+ return error
207
+ }
208
+
85
209
  /**
86
210
  * Weaverse API version path
87
211
  */
88
212
  const API_PATH = 'v1' as const
89
213
 
90
214
  /**
91
- * Default cache strategy for Weaverse API requests.
92
- * Optimized for CDN performance with 23-hour stale content tolerance.
215
+ * Cache strategy for Weaverse API requests: fresh for a minute, then served
216
+ * stale for a day while it refreshes or while Builder errors.
217
+ *
218
+ * Every Builder resource uses it. Hydrogen 2026.4's subrequest cache only read
219
+ * `max-age` for freshness, so the old per-resource `sMaxAge` values never
220
+ * changed how long an entry stayed fresh.
93
221
  */
94
- const DEFAULT_CACHE_STRATEGY: CachingStrategy = {
222
+ const DEFAULT_CACHE_STRATEGY: CachingStrategy = Cache({
95
223
  maxAge: CACHE_DURATIONS.SHORT,
96
- sMaxAge: CACHE_DURATIONS.SHARED_SHORT,
97
224
  staleWhileRevalidate: CACHE_DURATIONS.LONG,
98
225
  staleIfError: CACHE_DURATIONS.LONG,
99
- } as const
226
+ })
100
227
 
101
228
  /** Builder API resource whose freshness policy should be used for a request. */
102
229
  export type BuilderApiCacheTarget =
@@ -107,9 +234,9 @@ export type BuilderApiCacheTarget =
107
234
 
108
235
  /** Fetch options accepted by {@link WeaverseClient.fetchWithCache}. */
109
236
  export interface WeaverseFetchWithCacheOptions extends RequestInit {
110
- /** Builder API resource whose default freshness policy should be used. */
237
+ /** Builder API resource the request reads; part of the cache key. */
111
238
  cacheTarget?: BuilderApiCacheTarget
112
- /** Explicit Hydrogen caching strategy overriding the resource default. */
239
+ /** Explicit Hydrogen caching strategy overriding the default. */
113
240
  strategy?: CachingStrategy
114
241
  }
115
242
 
@@ -121,18 +248,6 @@ export interface FetchCustomPagesOptions {
121
248
  locale?: string
122
249
  }
123
250
 
124
- const SMART_CACHE_STRATEGIES: Record<BuilderApiCacheTarget, CachingStrategy> = {
125
- project: DEFAULT_CACHE_STRATEGY,
126
- 'theme-settings': DEFAULT_CACHE_STRATEGY,
127
- 'merchant-overrides': DEFAULT_CACHE_STRATEGY,
128
- 'custom-pages': {
129
- maxAge: CACHE_DURATIONS.SHORT,
130
- sMaxAge: CACHE_DURATIONS.STATIC,
131
- staleWhileRevalidate: CACHE_DURATIONS.LONG,
132
- staleIfError: CACHE_DURATIONS.LONG,
133
- },
134
- } as const
135
-
136
251
  /**
137
252
  * Options objects created by the internal `project_configs` request whose
138
253
  * subrequest cache identity deliberately excludes the body. Theme settings
@@ -176,12 +291,12 @@ export class WeaverseClient {
176
291
  basePageRequestBody: Omit<FetchProjectRequestBody, 'url'>
177
292
  /** Resolved project and rendering-mode configuration. */
178
293
  configs: WeaverseProjectConfigs
179
- /** Hydrogen cache wrapper used for Builder API requests. */
180
- withCache: ReturnType<typeof createWithCache>
294
+ /** Hydrogen cached fetch used for Builder API requests. */
295
+ private cachedFetch: ReturnType<typeof createFetchWithCache>
181
296
 
182
297
  /** Incoming request associated with this client instance. */
183
298
  request: WeaverseClientArgs['request']
184
- /** Hydrogen customer-account client, when configured. */
299
+ /** Customer Account API client, when the storefront passes one. */
185
300
  customerAccount: WeaverseClientArgs['customerAccount']
186
301
  /** Hydrogen Storefront API client and locale context. */
187
302
  storefront: WeaverseClientArgs['storefront']
@@ -189,21 +304,22 @@ export class WeaverseClient {
189
304
  components: WeaverseClientArgs['components']
190
305
  /** Theme schema used for defaults and Studio editing. */
191
306
  themeSchema: WeaverseClientArgs['themeSchema']
192
- /** Hydrogen runtime environment variables. */
307
+ /** Runtime environment variables. */
193
308
  env: WeaverseClientArgs['env']
194
309
  /** Cache storage supplied by the Hydrogen runtime. */
195
310
  cache: WeaverseClientArgs['cache']
196
- /** Background execution hook supplied by the Oxygen runtime. */
311
+ /** Background execution hook supplied by the runtime. */
197
312
  waitUntil: WeaverseClientArgs['waitUntil']
198
313
 
199
314
  // Performance optimizations
200
315
  private readonly parsedUrl: URL
201
- private readonly componentsByType: Map<string, HydrogenComponent>
316
+ private readonly componentsByType: Map<string, WeaverseComponent>
202
317
 
203
318
  // Configuration
204
319
  private readonly fetchTimeoutMs: number
320
+ private readonly throwPageLoadFailures: boolean
205
321
 
206
- /** Creates a request-scoped client from the Hydrogen app context. */
322
+ /** Creates a request-scoped client from the storefront's request context. */
207
323
  constructor(args: WeaverseClientArgs) {
208
324
  // Assign required dependencies
209
325
  this.request = args.request
@@ -221,6 +337,7 @@ export class WeaverseClient {
221
337
 
222
338
  // Configuration
223
339
  this.fetchTimeoutMs = args.fetchTimeoutMs || DEFAULT_FETCH_TIMEOUT_MS
340
+ this.throwPageLoadFailures = args.throwPageLoadFailures === true
224
341
 
225
342
  // Initialize cache and configs
226
343
  this.initializeCache(args)
@@ -238,10 +355,9 @@ export class WeaverseClient {
238
355
  )
239
356
  }
240
357
 
241
- this.withCache = createWithCache({
358
+ this.cachedFetch = createFetchWithCache({
242
359
  cache: args.cache,
243
- waitUntil: args.waitUntil || (() => Promise.resolve()),
244
- request: args.request,
360
+ waitUntil: args.waitUntil,
245
361
  })
246
362
  }
247
363
 
@@ -251,16 +367,13 @@ export class WeaverseClient {
251
367
  */
252
368
  private initializeConfigs(args: WeaverseClientArgs): void {
253
369
  // Get base configs once to avoid duplicate calls
254
- const baseConfigs = getWeaverseConfigs(
255
- args.request,
256
- args.env as HydrogenEnv
257
- )
370
+ const baseConfigs = getWeaverseConfigs(args.request, args.env)
258
371
 
259
372
  // Resolve projectId with multi-project support
260
373
  const resolvedProjectId = this.resolveProjectIdSync(
261
374
  args.projectId,
262
375
  args.request,
263
- args.env as HydrogenEnv,
376
+ args.env,
264
377
  baseConfigs
265
378
  )
266
379
 
@@ -308,8 +421,8 @@ export class WeaverseClient {
308
421
  * Significantly improves performance for large component lists.
309
422
  */
310
423
  private buildComponentLookupMap(
311
- components: HydrogenComponent[]
312
- ): Map<string, HydrogenComponent> {
424
+ components: WeaverseComponent[]
425
+ ): Map<string, WeaverseComponent> {
313
426
  return new Map(components.map((comp) => [comp.schema?.type, comp]))
314
427
  }
315
428
 
@@ -407,7 +520,7 @@ export class WeaverseClient {
407
520
  private resolveProjectIdSync(
408
521
  projectId: string | (() => string) | (() => Promise<string>) | undefined,
409
522
  request: Request,
410
- env: HydrogenEnv,
523
+ env: WeaverseEnv,
411
524
  baseConfigs?: WeaverseProjectConfigs
412
525
  ): string {
413
526
  return (
@@ -525,7 +638,7 @@ export class WeaverseClient {
525
638
  private resolveFromEnvironment(
526
639
  baseConfigs: WeaverseProjectConfigs | undefined,
527
640
  request: Request,
528
- env: HydrogenEnv
641
+ env: WeaverseEnv
529
642
  ): string | null {
530
643
  const configs = baseConfigs || getWeaverseConfigs(request, env)
531
644
 
@@ -538,7 +651,6 @@ export class WeaverseClient {
538
651
 
539
652
  /**
540
653
  * Helper method for direct fetching in design mode.
541
- * Returns same format as withCache.fetch for consistency.
542
654
  * Includes 10-second timeout to prevent hanging requests.
543
655
  */
544
656
  private readonly fetchOnce = async <T>(
@@ -568,7 +680,22 @@ export class WeaverseClient {
568
680
  )
569
681
  }
570
682
 
571
- const data = (await response.json()) as T
683
+ let data: T
684
+ try {
685
+ data = (await response.json()) as T
686
+ } catch (parseError) {
687
+ // A cut-short body still times out; anything else is a successful
688
+ // status whose body is not JSON, which is not a network failure.
689
+ if (parseError?.name === 'AbortError') {
690
+ throw parseError
691
+ }
692
+ throw new WeaverseError(
693
+ 'Response body is not valid JSON',
694
+ 'INVALID_RESPONSE',
695
+ response.status,
696
+ { url }
697
+ )
698
+ }
572
699
  return { data, response }
573
700
  } catch (err) {
574
701
  if (err instanceof WeaverseError) {
@@ -635,7 +762,7 @@ export class WeaverseClient {
635
762
 
636
763
  /**
637
764
  * Fetch data with caching support for both design mode and production.
638
- * Uses direct fetch in design mode, Hydrogen's withCache in production.
765
+ * Uses direct fetch in design mode, Hydrogen's cached fetch in production.
639
766
  * Automatically includes projectId in cache key for multi-project isolation.
640
767
  *
641
768
  * @param url - The URL to fetch from
@@ -655,13 +782,8 @@ export class WeaverseClient {
655
782
  options: WeaverseFetchWithCacheOptions = {}
656
783
  ): Promise<T> => {
657
784
  const { cacheTarget, strategy: strategyOverride, ...fetchOptions } = options
658
- const strategy =
659
- strategyOverride ||
660
- CacheCustom(
661
- cacheTarget
662
- ? SMART_CACHE_STRATEGIES[cacheTarget]
663
- : DEFAULT_CACHE_STRATEGY
664
- )
785
+ const strategy = strategyOverride || DEFAULT_CACHE_STRATEGY
786
+ const { body } = fetchOptions
665
787
 
666
788
  // Update cache key to include method, body content, and projectId for multi-project isolation
667
789
  // Prefix for easier debugging in cache systems
@@ -676,7 +798,9 @@ export class WeaverseClient {
676
798
  options.method || 'GET',
677
799
  bodyFreeCacheRequests.has(options)
678
800
  ? BODY_FREE_CACHE_SENTINEL
679
- : fetchOptions.body,
801
+ : body == null
802
+ ? null
803
+ : String(body),
680
804
  // WeakSet-derived discriminator: this slot cannot be produced by any
681
805
  // caller-supplied shape, so no external call can reach the marked
682
806
  // identity — even one that happens to send the sentinel string as its
@@ -685,40 +809,66 @@ export class WeaverseClient {
685
809
  this.configs.projectId,
686
810
  cacheTarget || 'default',
687
811
  ]
688
- let result: WithCacheFetchResponse<T>
689
812
 
690
813
  // Bypass the shared Hydrogen subrequest cache for design/revision modes
691
814
  // and for the Cloudflare public API proxy. The proxy owns freshness with
692
815
  // versioned cache keys; keeping Hydrogen's URL/body cache in front would
693
816
  // keep serving a stale response even after Builder bumps the proxy's
694
817
  // project version.
818
+ const validateBuilderPayload = builderPayloadValidators.get(options)
819
+
695
820
  if (
696
821
  this.configs.isDesignMode ||
697
822
  this.configs.isRevisionPreview ||
698
823
  this.isExternalPublicApiUrl(url)
699
824
  ) {
700
- result = await this.directFetch<T>(url, fetchOptions)
701
- } else {
702
- // Use withCache.fetch for better integration with Hydrogen's caching system
703
- result = (await this.withCache.fetch(url, fetchOptions, {
704
- cacheKey,
705
- cacheStrategy: strategy,
706
- shouldCacheResponse: (response: any): boolean => {
825
+ const result = await this.directFetch<T>(url, fetchOptions)
826
+ return result.data
827
+ }
828
+
829
+ let response: Response
830
+ let text: string
831
+ try {
832
+ response = await this.cachedFetch(url, fetchOptions, {
833
+ key: cacheKey,
834
+ strategy,
835
+ shouldCacheResponse: async ({ text: readText }) => {
707
836
  // An aborted request never caches: Hydrogen turns a body read cut
708
- // short by the abort into an empty payload, not an error.
837
+ // short by the abort into an empty body, not an error.
709
838
  if (fetchOptions.signal?.aborted) {
710
839
  return false
711
840
  }
841
+ const payload = parseResponseText(await readText())
842
+ if (validateBuilderPayload && !validateBuilderPayload(payload)) {
843
+ return false
844
+ }
712
845
  // Cache any non-error response (for both Weaverse API and third-party APIs)
713
- return (
714
- !hasError(response) && response !== null && response !== undefined
715
- )
846
+ return !hasError(payload) && payload !== null && payload !== ''
716
847
  },
717
- displayName: 'Weaverse API',
718
- })) as WithCacheFetchResponse<T>
848
+ })
849
+ text = await response.text()
850
+ } catch (error) {
851
+ // Only this client's own Builder requests are normalized; a
852
+ // third-party `fetchWithCache` caller keeps the platform error.
853
+ throw validateBuilderPayload ? cachedTransportError(error, url) : error
719
854
  }
720
855
 
721
- return result.data
856
+ if (!response.ok) {
857
+ // For this client's own Builder requests a non-2xx status must not read
858
+ // as "nothing published"; surface the status the direct path already
859
+ // throws instead. Other callers keep the `null` body they always got.
860
+ if (validateBuilderPayload) {
861
+ throw new WeaverseError(
862
+ `HTTP ${response.status}: ${response.statusText}`,
863
+ 'FETCH_ERROR',
864
+ response.status,
865
+ { url }
866
+ )
867
+ }
868
+ return null as T
869
+ }
870
+
871
+ return parseResponseText(text) as T
722
872
  }
723
873
 
724
874
  /**
@@ -750,7 +900,7 @@ export class WeaverseClient {
750
900
  */
751
901
  loadThemeSettings = async (
752
902
  strategy?: CachingStrategy
753
- ): Promise<ThemeSettingsResponse> => {
903
+ ): Promise<WeaverseThemeSettingsResponse> => {
754
904
  const defaultThemeSettings = generateDataFromSchema(this.themeSchema)
755
905
  try {
756
906
  const { configs } = this
@@ -793,32 +943,28 @@ export class WeaverseClient {
793
943
  },
794
944
  }
795
945
  bodyFreeCacheRequests.add(themeSettingsOptions)
946
+ builderPayloadValidators.set(themeSettingsOptions, isThemeConfigsEnvelope)
796
947
 
797
948
  const [data, merchantOverrides] = await Promise.all([
798
- this.fetchWithCache<ThemeSettingsResponse>(url, themeSettingsOptions),
949
+ this.fetchWithCache<WeaverseThemeSettingsResponse>(
950
+ url,
951
+ themeSettingsOptions
952
+ ),
799
953
  this.fetchMerchantOverrides(strategy),
800
954
  ])
801
955
 
802
- let result = data
803
-
804
- // Validate that data is a proper object, not corrupted/compressed content
805
- if (typeof result !== 'object' || result === null) {
806
- console.warn(
807
- 'Invalid theme settings response format, using defaults:',
808
- {
809
- type: typeof result,
810
- isString: typeof result === 'string',
811
- length:
812
- typeof result === 'string' ? (result as string).length : 'N/A',
813
- preview:
814
- typeof result === 'string'
815
- ? `${(result as string).slice(0, 50)}...`
816
- : String(result),
817
- }
956
+ // A body without a `theme` field (corrupted content, an error body, an
957
+ // empty object) is not the merchant's configuration. Report it like any
958
+ // other failure so schema defaults are never presented as loaded.
959
+ if (!isThemeConfigsEnvelope(data)) {
960
+ throw new WeaverseError(
961
+ 'Invalid theme settings response format',
962
+ 'INVALID_RESPONSE'
818
963
  )
819
- result = { theme: defaultThemeSettings }
820
964
  }
821
965
 
966
+ let result = data
967
+
822
968
  if (this.themeSchema?.settings || this.themeSchema?.inspector) {
823
969
  // Ensure result.theme exists and is an object before merging
824
970
  if (!result.theme || typeof result.theme !== 'object') {
@@ -954,9 +1100,9 @@ export class WeaverseClient {
954
1100
  * Used internally when API returns no page or when page needs default content.
955
1101
  *
956
1102
  * @param message - Custom message to display in the fallback page
957
- * @returns HydrogenPageData with single main component containing the message
1103
+ * @returns WeaversePageData with single main component containing the message
958
1104
  */
959
- generateFallbackPage = (message: string): HydrogenPageData => {
1105
+ generateFallbackPage = (message: string): WeaversePageData => {
960
1106
  const rootId = crypto.randomUUID()
961
1107
  return {
962
1108
  id: `fallback_page_${Date.now()}`,
@@ -1060,6 +1206,24 @@ export class WeaverseClient {
1060
1206
  loadPage = async (
1061
1207
  params: LoadPageParams = {}
1062
1208
  ): Promise<WeaverseLoaderData | null> => {
1209
+ // Validate route-level projectId if provided
1210
+ if (params.projectId !== undefined) {
1211
+ const message =
1212
+ typeof params.projectId === 'string'
1213
+ ? this.validateProjectId(params.projectId).error
1214
+ : `must be a string. Received: ${typeof params.projectId}`
1215
+ if (message) {
1216
+ console.error(`Route-level projectId validation failed: ${message}`)
1217
+ if (this.throwPageLoadFailures) {
1218
+ throw new WeaverseLoadError(
1219
+ 'Route-level projectId validation failed',
1220
+ 'configuration'
1221
+ )
1222
+ }
1223
+ return null
1224
+ }
1225
+ }
1226
+
1063
1227
  try {
1064
1228
  const { request, storefront, basePageRequestBody, basePageConfigs } = this
1065
1229
  const {
@@ -1069,25 +1233,6 @@ export class WeaverseClient {
1069
1233
  sectionType,
1070
1234
  } = this.configs
1071
1235
 
1072
- // Validate route-level projectId if provided
1073
- if (params.projectId !== undefined) {
1074
- if (typeof params.projectId !== 'string') {
1075
- console.error(
1076
- 'Route-level projectId validation failed: must be a string. ' +
1077
- `Received: ${typeof params.projectId}`
1078
- )
1079
- return null
1080
- }
1081
-
1082
- const validation = this.validateProjectId(params.projectId)
1083
- if (!validation.valid) {
1084
- console.error(
1085
- `Route-level projectId validation failed: ${validation.error}`
1086
- )
1087
- return null
1088
- }
1089
- }
1090
-
1091
1236
  // Override client-level projectId with route-level projectId if provided
1092
1237
  const effectiveProjectId = params.projectId || clientProjectId
1093
1238
 
@@ -1096,14 +1241,17 @@ export class WeaverseClient {
1096
1241
  }
1097
1242
 
1098
1243
  if (!effectiveProjectId) {
1099
- throw new Error('Missing Weaverse projectId!')
1244
+ throw new WeaverseLoadError(
1245
+ 'Missing Weaverse projectId!',
1246
+ 'configuration'
1247
+ )
1100
1248
  }
1101
1249
 
1102
1250
  const { strategy, ...pageLoadParams } = params
1103
1251
  const body: {
1104
1252
  projectId: string
1105
1253
  url: string
1106
- i18n?: I18nBase
1254
+ i18n?: WeaverseI18n
1107
1255
  params?: {
1108
1256
  type?: PageType
1109
1257
  locale?: string
@@ -1131,40 +1279,45 @@ export class WeaverseClient {
1131
1279
  }
1132
1280
  const url = this.getApiUrl('project')
1133
1281
 
1282
+ const projectOptions: WeaverseFetchWithCacheOptions = {
1283
+ ...reqInit,
1284
+ cacheTarget: 'project',
1285
+ strategy,
1286
+ }
1287
+ builderPayloadValidators.set(projectOptions, isProjectEnvelope)
1134
1288
  const projectData: FetchProjectPayload =
1135
- await this.fetchWithCache<FetchProjectPayload>(url, {
1136
- ...reqInit,
1137
- cacheTarget: 'project',
1138
- strategy,
1139
- })
1289
+ await this.fetchWithCache<FetchProjectPayload>(url, projectOptions)
1140
1290
 
1141
1291
  if (hasError(projectData)) {
1142
- throw new Error(projectData.error)
1292
+ throw new WeaverseLoadError(
1293
+ 'Weaverse API returned an error body',
1294
+ 'invalid-response'
1295
+ )
1143
1296
  }
1144
1297
 
1145
- // Extract what we can from the response using type guard
1146
- // If validation fails, use fallback data instead of throwing
1147
- let page: HydrogenPageData | undefined
1148
- let project: WeaverseLoaderData['project'] | undefined
1149
- let pageAssignment: WeaverseLoaderData['pageAssignment'] | undefined
1150
-
1151
- if (isFetchProjectPayload(projectData)) {
1152
- // Valid response - extract normally
1153
- page = projectData.page
1154
- project = projectData.project
1155
- pageAssignment = projectData.pageAssignment
1156
- } else {
1298
+ if (!isProjectEnvelope(projectData)) {
1299
+ // A body that does not answer for this URL is not "nothing assigned
1300
+ // here". Without the opt-in, keep the historical fallback page.
1301
+ if (this.throwPageLoadFailures) {
1302
+ throw new WeaverseLoadError(
1303
+ 'Invalid project data structure received from API',
1304
+ 'invalid-response'
1305
+ )
1306
+ }
1157
1307
  console.warn(
1158
1308
  'Invalid project data structure received from API, using fallback data',
1159
1309
  { projectId: effectiveProjectId }
1160
1310
  )
1161
- // Try to extract what we can from the malformed response
1162
- const responseData = projectData as any
1163
- page = responseData?.page
1164
- project = responseData?.project
1165
- pageAssignment = responseData?.pageAssignment
1166
1311
  }
1167
1312
 
1313
+ // Extract what we can; a legacy-mode malformed body may lack any field.
1314
+ const responseData = projectData as Partial<FetchProjectPayload> | null
1315
+ let page: WeaversePageData | undefined = responseData?.page ?? undefined
1316
+ let project: WeaverseLoaderData['project'] | undefined =
1317
+ responseData?.project
1318
+ const pageAssignment: WeaverseLoaderData['pageAssignment'] | undefined =
1319
+ responseData?.pageAssignment
1320
+
1168
1321
  if (!project) {
1169
1322
  console.warn('Weaverse project not found. Id:', effectiveProjectId)
1170
1323
  project = {
@@ -1207,6 +1360,7 @@ export class WeaverseClient {
1207
1360
  },
1208
1361
  }
1209
1362
  } catch (error) {
1363
+ const failure = classifyPageLoadFailure(error)
1210
1364
  const errorMessage = '❌ Page load failed'
1211
1365
  const errorContext: Record<string, unknown> = {
1212
1366
  projectId: params.projectId || this.configs.projectId,
@@ -1232,12 +1386,23 @@ export class WeaverseClient {
1232
1386
  })
1233
1387
  }
1234
1388
 
1235
- return null
1389
+ if (failure === 'absent' || !this.throwPageLoadFailures) {
1390
+ return null
1391
+ }
1392
+ if (error instanceof WeaverseLoadError) {
1393
+ throw error
1394
+ }
1395
+ // Fixed message: the cause can carry request-derived detail.
1396
+ throw new WeaverseLoadError(
1397
+ 'Unable to load Weaverse page',
1398
+ failure,
1399
+ error instanceof WeaverseError ? error.statusCode : undefined
1400
+ )
1236
1401
  }
1237
1402
  }
1238
1403
 
1239
1404
  /** Executes the registered loader for one component and attaches its result. */
1240
- execComponentLoader = async (item: HydrogenComponentData) => {
1405
+ execComponentLoader = async (item: WeaverseComponentData) => {
1241
1406
  const { data = {}, type, id } = item
1242
1407
  const component = this.componentsByType.get(type)
1243
1408
  const loader = component?.loader