@weaverse/hydrogen 5.23.0 → 5.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,9 +24,10 @@ import type {
24
24
  } from './types'
25
25
  import {
26
26
  hasError,
27
- isFetchProjectPayload,
28
27
  isProjectIdFunction,
29
28
  WeaverseError,
29
+ WeaverseLoadError,
30
+ type WeaverseLoadFailureKind,
30
31
  } from './types'
31
32
  import {
32
33
  generateDataFromSchema,
@@ -76,12 +77,130 @@ function isRetryableFetchError(error: unknown): boolean {
76
77
  if (!(error instanceof WeaverseError)) {
77
78
  return false
78
79
  }
79
- if (error.code === 'TIMEOUT_ERROR' || error.code === 'NETWORK_ERROR') {
80
+ // A successful status with an unparsable body was classified as a network
81
+ // error before it got its own code; it keeps the same single retry.
82
+ if (
83
+ error.code === 'TIMEOUT_ERROR' ||
84
+ error.code === 'NETWORK_ERROR' ||
85
+ error.code === 'INVALID_RESPONSE'
86
+ ) {
80
87
  return true
81
88
  }
82
89
  return error.code === 'FETCH_ERROR' && (error.statusCode ?? 0) >= 500
83
90
  }
84
91
 
92
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
93
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
94
+ }
95
+
96
+ /**
97
+ * Whether a successful `/project` body is a page envelope.
98
+ *
99
+ * `page: null` is Builder's authoritative "nothing assigned at this URL" and
100
+ * stays valid; anything without a `page` field, with an `error` field, or with
101
+ * a page that has no item list is not an answer about this URL at all.
102
+ */
103
+ function isProjectEnvelope(payload: unknown): boolean {
104
+ if (!isPlainObject(payload) || hasError(payload) || !('page' in payload)) {
105
+ return false
106
+ }
107
+ const { page, project } = payload
108
+ const validPage =
109
+ page === null || (isPlainObject(page) && Array.isArray(page.items))
110
+ return validPage && (project == null || isPlainObject(project))
111
+ }
112
+
113
+ /**
114
+ * Whether a successful `/project_configs` body carries theme settings.
115
+ *
116
+ * Builder always sends the `theme` field; `null` means the merchant never
117
+ * saved settings, so schema defaults are the real configuration.
118
+ */
119
+ function isThemeConfigsEnvelope(payload: unknown): boolean {
120
+ return (
121
+ isPlainObject(payload) &&
122
+ !hasError(payload) &&
123
+ 'theme' in payload &&
124
+ (payload.theme === null || isPlainObject(payload.theme))
125
+ )
126
+ }
127
+
128
+ /**
129
+ * Classifies a page-load failure. `'absent'` is Builder's 404 for a project
130
+ * that does not exist, which keeps resolving to `null` like before.
131
+ */
132
+ function classifyPageLoadFailure(
133
+ error: unknown
134
+ ): WeaverseLoadFailureKind | 'absent' {
135
+ if (error instanceof WeaverseLoadError) {
136
+ return error.kind
137
+ }
138
+ if (!(error instanceof WeaverseError)) {
139
+ // Thrown while turning a response into page data: the body was not
140
+ // the shape it claimed to be.
141
+ return 'invalid-response'
142
+ }
143
+ if (error.code === 'TIMEOUT_ERROR' || error.code === 'NETWORK_ERROR') {
144
+ return 'unavailable'
145
+ }
146
+ if (error.code === 'INVALID_RESPONSE') {
147
+ return 'invalid-response'
148
+ }
149
+ if (error.code === 'FETCH_ERROR') {
150
+ const status = error.statusCode ?? 0
151
+ if (status === 404) {
152
+ return 'absent'
153
+ }
154
+ if (status === 408 || status === 425 || status === 429 || status >= 500) {
155
+ return 'unavailable'
156
+ }
157
+ }
158
+ // Refusals (400/401/403) and project-ID errors: retrying cannot succeed.
159
+ return 'configuration'
160
+ }
161
+
162
+ /**
163
+ * Validators for Builder requests made by this client itself, keyed on the
164
+ * exact options object (same reasoning as `bodyFreeCacheRequests`). On the
165
+ * cached path such a request rejects a non-2xx status instead of resolving
166
+ * to `null`, and only stores a body its validator accepts. Every other
167
+ * `fetchWithCache` caller keeps the existing behavior.
168
+ */
169
+ const builderPayloadValidators = new WeakMap<
170
+ object,
171
+ (payload: unknown) => boolean
172
+ >()
173
+
174
+ /**
175
+ * Gives a rejection from Hydrogen's cached fetch the code `fetchOnce` gives
176
+ * the same failure on the direct path.
177
+ *
178
+ * Hydrogen calls the platform `fetch` itself, so a request that never got a
179
+ * response rejects with the platform's error instead of a `WeaverseError`.
180
+ * The Fetch standard rejects a network failure with a `TypeError` and an
181
+ * aborted request with the signal's `AbortError`/`TimeoutError`; those are
182
+ * outages. Anything else is returned unchanged, so it is not reported as one.
183
+ */
184
+ function cachedTransportError(error: unknown, url: string): unknown {
185
+ if (error instanceof WeaverseError || !(error instanceof Error)) {
186
+ return error
187
+ }
188
+ if (error.name === 'AbortError' || error.name === 'TimeoutError') {
189
+ return new WeaverseError('Request timed out', 'TIMEOUT_ERROR', undefined, {
190
+ url,
191
+ })
192
+ }
193
+ if (error instanceof TypeError) {
194
+ return new WeaverseError(
195
+ 'Network error during fetch',
196
+ 'NETWORK_ERROR',
197
+ undefined,
198
+ { url, cause: error.message }
199
+ )
200
+ }
201
+ return error
202
+ }
203
+
85
204
  /**
86
205
  * Weaverse API version path
87
206
  */
@@ -202,6 +321,7 @@ export class WeaverseClient {
202
321
 
203
322
  // Configuration
204
323
  private readonly fetchTimeoutMs: number
324
+ private readonly throwPageLoadFailures: boolean
205
325
 
206
326
  /** Creates a request-scoped client from the Hydrogen app context. */
207
327
  constructor(args: WeaverseClientArgs) {
@@ -221,6 +341,7 @@ export class WeaverseClient {
221
341
 
222
342
  // Configuration
223
343
  this.fetchTimeoutMs = args.fetchTimeoutMs || DEFAULT_FETCH_TIMEOUT_MS
344
+ this.throwPageLoadFailures = args.throwPageLoadFailures === true
224
345
 
225
346
  // Initialize cache and configs
226
347
  this.initializeCache(args)
@@ -568,7 +689,22 @@ export class WeaverseClient {
568
689
  )
569
690
  }
570
691
 
571
- const data = (await response.json()) as T
692
+ let data: T
693
+ try {
694
+ data = (await response.json()) as T
695
+ } catch (parseError) {
696
+ // A cut-short body still times out; anything else is a successful
697
+ // status whose body is not JSON, which is not a network failure.
698
+ if (parseError?.name === 'AbortError') {
699
+ throw parseError
700
+ }
701
+ throw new WeaverseError(
702
+ 'Response body is not valid JSON',
703
+ 'INVALID_RESPONSE',
704
+ response.status,
705
+ { url }
706
+ )
707
+ }
572
708
  return { data, response }
573
709
  } catch (err) {
574
710
  if (err instanceof WeaverseError) {
@@ -692,6 +828,8 @@ export class WeaverseClient {
692
828
  // versioned cache keys; keeping Hydrogen's URL/body cache in front would
693
829
  // keep serving a stale response even after Builder bumps the proxy's
694
830
  // project version.
831
+ const validateBuilderPayload = builderPayloadValidators.get(options)
832
+
695
833
  if (
696
834
  this.configs.isDesignMode ||
697
835
  this.configs.isRevisionPreview ||
@@ -700,7 +838,7 @@ export class WeaverseClient {
700
838
  result = await this.directFetch<T>(url, fetchOptions)
701
839
  } else {
702
840
  // Use withCache.fetch for better integration with Hydrogen's caching system
703
- result = (await this.withCache.fetch(url, fetchOptions, {
841
+ const cachedFetch = this.withCache.fetch(url, fetchOptions, {
704
842
  cacheKey,
705
843
  cacheStrategy: strategy,
706
844
  shouldCacheResponse: (response: any): boolean => {
@@ -709,13 +847,36 @@ export class WeaverseClient {
709
847
  if (fetchOptions.signal?.aborted) {
710
848
  return false
711
849
  }
850
+ if (validateBuilderPayload && !validateBuilderPayload(response)) {
851
+ return false
852
+ }
712
853
  // Cache any non-error response (for both Weaverse API and third-party APIs)
713
854
  return (
714
855
  !hasError(response) && response !== null && response !== undefined
715
856
  )
716
857
  },
717
858
  displayName: 'Weaverse API',
718
- })) as WithCacheFetchResponse<T>
859
+ })
860
+ // Only this client's own Builder requests are normalized; a
861
+ // third-party `fetchWithCache` caller keeps the platform error.
862
+ result = (await (validateBuilderPayload
863
+ ? cachedFetch.catch((error: unknown) => {
864
+ throw cachedTransportError(error, url)
865
+ })
866
+ : cachedFetch)) as WithCacheFetchResponse<T>
867
+
868
+ // Hydrogen resolves a non-2xx status with a `null` body. For this
869
+ // client's own Builder requests that would read as "nothing published";
870
+ // surface the status the direct path already throws instead.
871
+ const { response } = result
872
+ if (validateBuilderPayload && response && !response.ok) {
873
+ throw new WeaverseError(
874
+ `HTTP ${response.status}: ${response.statusText}`,
875
+ 'FETCH_ERROR',
876
+ response.status,
877
+ { url }
878
+ )
879
+ }
719
880
  }
720
881
 
721
882
  return result.data
@@ -793,32 +954,25 @@ export class WeaverseClient {
793
954
  },
794
955
  }
795
956
  bodyFreeCacheRequests.add(themeSettingsOptions)
957
+ builderPayloadValidators.set(themeSettingsOptions, isThemeConfigsEnvelope)
796
958
 
797
959
  const [data, merchantOverrides] = await Promise.all([
798
960
  this.fetchWithCache<ThemeSettingsResponse>(url, themeSettingsOptions),
799
961
  this.fetchMerchantOverrides(strategy),
800
962
  ])
801
963
 
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
- }
964
+ // A body without a `theme` field (corrupted content, an error body, an
965
+ // empty object) is not the merchant's configuration. Report it like any
966
+ // other failure so schema defaults are never presented as loaded.
967
+ if (!isThemeConfigsEnvelope(data)) {
968
+ throw new WeaverseError(
969
+ 'Invalid theme settings response format',
970
+ 'INVALID_RESPONSE'
818
971
  )
819
- result = { theme: defaultThemeSettings }
820
972
  }
821
973
 
974
+ let result = data
975
+
822
976
  if (this.themeSchema?.settings || this.themeSchema?.inspector) {
823
977
  // Ensure result.theme exists and is an object before merging
824
978
  if (!result.theme || typeof result.theme !== 'object') {
@@ -1060,6 +1214,24 @@ export class WeaverseClient {
1060
1214
  loadPage = async (
1061
1215
  params: LoadPageParams = {}
1062
1216
  ): Promise<WeaverseLoaderData | null> => {
1217
+ // Validate route-level projectId if provided
1218
+ if (params.projectId !== undefined) {
1219
+ const message =
1220
+ typeof params.projectId === 'string'
1221
+ ? this.validateProjectId(params.projectId).error
1222
+ : `must be a string. Received: ${typeof params.projectId}`
1223
+ if (message) {
1224
+ console.error(`Route-level projectId validation failed: ${message}`)
1225
+ if (this.throwPageLoadFailures) {
1226
+ throw new WeaverseLoadError(
1227
+ 'Route-level projectId validation failed',
1228
+ 'configuration'
1229
+ )
1230
+ }
1231
+ return null
1232
+ }
1233
+ }
1234
+
1063
1235
  try {
1064
1236
  const { request, storefront, basePageRequestBody, basePageConfigs } = this
1065
1237
  const {
@@ -1069,25 +1241,6 @@ export class WeaverseClient {
1069
1241
  sectionType,
1070
1242
  } = this.configs
1071
1243
 
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
1244
  // Override client-level projectId with route-level projectId if provided
1092
1245
  const effectiveProjectId = params.projectId || clientProjectId
1093
1246
 
@@ -1096,7 +1249,10 @@ export class WeaverseClient {
1096
1249
  }
1097
1250
 
1098
1251
  if (!effectiveProjectId) {
1099
- throw new Error('Missing Weaverse projectId!')
1252
+ throw new WeaverseLoadError(
1253
+ 'Missing Weaverse projectId!',
1254
+ 'configuration'
1255
+ )
1100
1256
  }
1101
1257
 
1102
1258
  const { strategy, ...pageLoadParams } = params
@@ -1131,40 +1287,45 @@ export class WeaverseClient {
1131
1287
  }
1132
1288
  const url = this.getApiUrl('project')
1133
1289
 
1290
+ const projectOptions: WeaverseFetchWithCacheOptions = {
1291
+ ...reqInit,
1292
+ cacheTarget: 'project',
1293
+ strategy,
1294
+ }
1295
+ builderPayloadValidators.set(projectOptions, isProjectEnvelope)
1134
1296
  const projectData: FetchProjectPayload =
1135
- await this.fetchWithCache<FetchProjectPayload>(url, {
1136
- ...reqInit,
1137
- cacheTarget: 'project',
1138
- strategy,
1139
- })
1297
+ await this.fetchWithCache<FetchProjectPayload>(url, projectOptions)
1140
1298
 
1141
1299
  if (hasError(projectData)) {
1142
- throw new Error(projectData.error)
1300
+ throw new WeaverseLoadError(
1301
+ 'Weaverse API returned an error body',
1302
+ 'invalid-response'
1303
+ )
1143
1304
  }
1144
1305
 
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 {
1306
+ if (!isProjectEnvelope(projectData)) {
1307
+ // A body that does not answer for this URL is not "nothing assigned
1308
+ // here". Without the opt-in, keep the historical fallback page.
1309
+ if (this.throwPageLoadFailures) {
1310
+ throw new WeaverseLoadError(
1311
+ 'Invalid project data structure received from API',
1312
+ 'invalid-response'
1313
+ )
1314
+ }
1157
1315
  console.warn(
1158
1316
  'Invalid project data structure received from API, using fallback data',
1159
1317
  { projectId: effectiveProjectId }
1160
1318
  )
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
1319
  }
1167
1320
 
1321
+ // Extract what we can; a legacy-mode malformed body may lack any field.
1322
+ const responseData = projectData as Partial<FetchProjectPayload> | null
1323
+ let page: HydrogenPageData | undefined = responseData?.page ?? undefined
1324
+ let project: WeaverseLoaderData['project'] | undefined =
1325
+ responseData?.project
1326
+ const pageAssignment: WeaverseLoaderData['pageAssignment'] | undefined =
1327
+ responseData?.pageAssignment
1328
+
1168
1329
  if (!project) {
1169
1330
  console.warn('Weaverse project not found. Id:', effectiveProjectId)
1170
1331
  project = {
@@ -1207,6 +1368,7 @@ export class WeaverseClient {
1207
1368
  },
1208
1369
  }
1209
1370
  } catch (error) {
1371
+ const failure = classifyPageLoadFailure(error)
1210
1372
  const errorMessage = '❌ Page load failed'
1211
1373
  const errorContext: Record<string, unknown> = {
1212
1374
  projectId: params.projectId || this.configs.projectId,
@@ -1232,7 +1394,18 @@ export class WeaverseClient {
1232
1394
  })
1233
1395
  }
1234
1396
 
1235
- return null
1397
+ if (failure === 'absent' || !this.throwPageLoadFailures) {
1398
+ return null
1399
+ }
1400
+ if (error instanceof WeaverseLoadError) {
1401
+ throw error
1402
+ }
1403
+ // Fixed message: the cause can carry request-derived detail.
1404
+ throw new WeaverseLoadError(
1405
+ 'Unable to load Weaverse page',
1406
+ failure,
1407
+ error instanceof WeaverseError ? error.statusCode : undefined
1408
+ )
1236
1409
  }
1237
1410
  }
1238
1411