@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.
- package/dist/index.js +2294 -2115
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2277 -2083
- package/dist/index.mjs.map +1 -1
- package/dist/types/WeaverseHydrogenRoot.d.ts +1 -1
- package/dist/types/WeaverseHydrogenRoot.d.ts.map +1 -1
- package/dist/types/types.d.ts +45 -1
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/utils/translation-store.d.ts.map +1 -1
- package/dist/types/utils/use-theme-settings-store.d.ts.map +1 -1
- package/dist/types/weaverse-client.d.ts +1 -0
- package/dist/types/weaverse-client.d.ts.map +1 -1
- package/package.json +7 -21
- package/readme.md +33 -0
- package/src/types.ts +61 -0
- package/src/weaverse-client.ts +239 -66
package/src/weaverse-client.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
803
|
-
|
|
804
|
-
//
|
|
805
|
-
if (
|
|
806
|
-
|
|
807
|
-
'Invalid theme settings response format
|
|
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
|
|
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
|
|
1300
|
+
throw new WeaverseLoadError(
|
|
1301
|
+
'Weaverse API returned an error body',
|
|
1302
|
+
'invalid-response'
|
|
1303
|
+
)
|
|
1143
1304
|
}
|
|
1144
1305
|
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
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
|
-
|
|
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
|
|