@weaverse/hydrogen 5.17.1 → 5.18.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.
- package/dist/index.js +83 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +83 -1
- package/dist/index.mjs.map +1 -1
- package/dist/types/WeaverseHydrogenRoot.d.ts +218 -0
- package/dist/types/WeaverseHydrogenRoot.d.ts.map +1 -0
- package/dist/types/components/index.d.ts +4 -0
- package/dist/types/components/index.d.ts.map +1 -0
- package/dist/types/components/main.d.ts +7 -0
- package/dist/types/components/main.d.ts.map +1 -0
- package/dist/types/hooks/theme-text-context.d.ts +2 -0
- package/dist/types/hooks/theme-text-context.d.ts.map +1 -0
- package/dist/types/hooks/translation-context.d.ts +138 -0
- package/dist/types/hooks/translation-context.d.ts.map +1 -0
- package/dist/types/hooks/use-weaverse-data-context.d.ts +29 -0
- package/dist/types/hooks/use-weaverse-data-context.d.ts.map +1 -0
- package/dist/types/index.d.ts +99 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/seo.d.ts +43 -0
- package/dist/types/seo.d.ts.map +1 -0
- package/dist/types/types.d.ts +826 -0
- package/dist/types/types.d.ts.map +1 -0
- package/dist/types/utils/index.d.ts +44 -0
- package/dist/types/utils/index.d.ts.map +1 -0
- package/dist/types/utils/normalize-page-url.d.ts +16 -0
- package/dist/types/utils/normalize-page-url.d.ts.map +1 -0
- package/dist/types/utils/pick-weaverse-data.d.ts +44 -0
- package/dist/types/utils/pick-weaverse-data.d.ts.map +1 -0
- package/dist/types/utils/pixel.d.ts +16 -0
- package/dist/types/utils/pixel.d.ts.map +1 -0
- package/dist/types/utils/studio-request-info.d.ts +17 -0
- package/dist/types/utils/studio-request-info.d.ts.map +1 -0
- package/dist/types/utils/studio-script-src.d.ts +78 -0
- package/dist/types/utils/studio-script-src.d.ts.map +1 -0
- package/dist/types/utils/sync-reused-instance.d.ts +21 -0
- package/dist/types/utils/sync-reused-instance.d.ts.map +1 -0
- package/dist/types/utils/theme-text-store.d.ts +2 -0
- package/dist/types/utils/theme-text-store.d.ts.map +1 -0
- package/dist/types/utils/translation-store.d.ts +40 -0
- package/dist/types/utils/translation-store.d.ts.map +1 -0
- package/dist/types/utils/use-studio.d.ts +33 -0
- package/dist/types/utils/use-studio.d.ts.map +1 -0
- package/dist/types/utils/use-theme-settings-store.d.ts +39 -0
- package/dist/types/utils/use-theme-settings-store.d.ts.map +1 -0
- package/dist/types/weaverse-client.d.ts +227 -0
- package/dist/types/weaverse-client.d.ts.map +1 -0
- package/package.json +7 -6
- package/src/WeaverseHydrogenRoot.tsx +649 -0
- package/src/components/index.ts +10 -0
- package/src/components/main.tsx +28 -0
- package/src/hooks/theme-text-context.tsx +14 -0
- package/src/hooks/translation-context.tsx +275 -0
- package/src/hooks/use-weaverse-data-context.ts +69 -0
- package/src/index.ts +179 -0
- package/src/seo.ts +125 -0
- package/src/types.ts +1020 -0
- package/src/utils/index.ts +351 -0
- package/src/utils/normalize-page-url.ts +44 -0
- package/src/utils/pick-weaverse-data.ts +89 -0
- package/src/utils/pixel.ts +71 -0
- package/src/utils/studio-request-info.ts +66 -0
- package/src/utils/studio-script-src.ts +162 -0
- package/src/utils/sync-reused-instance.ts +57 -0
- package/src/utils/theme-text-store.ts +1 -0
- package/src/utils/translation-store.ts +65 -0
- package/src/utils/use-studio.ts +168 -0
- package/src/utils/use-theme-settings-store.ts +160 -0
- package/src/weaverse-client.ts +1179 -0
- package/dist/index.d.mts +0 -1260
- package/dist/index.d.ts +0 -1260
|
@@ -0,0 +1,1179 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CacheCustom,
|
|
3
|
+
createWithCache,
|
|
4
|
+
type HydrogenEnv,
|
|
5
|
+
type I18nBase,
|
|
6
|
+
} from '@shopify/hydrogen'
|
|
7
|
+
import type {
|
|
8
|
+
CachingStrategy,
|
|
9
|
+
CustomPageEntry,
|
|
10
|
+
FetchProjectPayload,
|
|
11
|
+
FetchProjectRequestBody,
|
|
12
|
+
HydrogenComponent,
|
|
13
|
+
HydrogenComponentData,
|
|
14
|
+
HydrogenPageData,
|
|
15
|
+
LoadPageParams,
|
|
16
|
+
PageType,
|
|
17
|
+
ProjectIdValidationResult,
|
|
18
|
+
ThemeSettingsResponse,
|
|
19
|
+
WeaverseClientArgs,
|
|
20
|
+
WeaverseLoaderData,
|
|
21
|
+
WeaverseProjectConfigs,
|
|
22
|
+
WeaverseStudioQueries,
|
|
23
|
+
WithCacheFetchResponse,
|
|
24
|
+
} from './types'
|
|
25
|
+
import {
|
|
26
|
+
hasError,
|
|
27
|
+
isFetchProjectPayload,
|
|
28
|
+
isProjectIdFunction,
|
|
29
|
+
WeaverseError,
|
|
30
|
+
} from './types'
|
|
31
|
+
import {
|
|
32
|
+
generateDataFromSchema,
|
|
33
|
+
getPreviewData,
|
|
34
|
+
getRequestQueries,
|
|
35
|
+
getWeaverseConfigs,
|
|
36
|
+
} from './utils'
|
|
37
|
+
import { normalizePageUrl } from './utils/normalize-page-url'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Cache duration constants (in seconds).
|
|
41
|
+
* These values optimize CDN hit rates while maintaining fresh content.
|
|
42
|
+
*/
|
|
43
|
+
const CACHE_DURATIONS = {
|
|
44
|
+
/** Short browser freshness for content that can change in Builder */
|
|
45
|
+
SHORT: 60,
|
|
46
|
+
/** Shared cache freshness for anonymous storefront traffic */
|
|
47
|
+
SHARED_SHORT: 300,
|
|
48
|
+
/** Long stale window so slow Builder responses do not block storefront rendering */
|
|
49
|
+
LONG: 86_400,
|
|
50
|
+
/** Extra freshness for sitemap-like index data */
|
|
51
|
+
STATIC: 3600,
|
|
52
|
+
} as const
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Network timeout for fetch requests (in milliseconds).
|
|
56
|
+
* Default is 10 seconds to balance reliability and user experience.
|
|
57
|
+
*/
|
|
58
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 10_000
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Bounded retry for the public API fetch. A single slow/cold origin response,
|
|
62
|
+
* a transient 5xx, or a network blip would otherwise make loadPage return null
|
|
63
|
+
* — rendering the "Please add new section" placeholder or throwing a 404 in
|
|
64
|
+
* the route. Two attempts (one retry) is enough: by the time the first attempt
|
|
65
|
+
* times out, the edge proxy's single-flight leader has usually populated its
|
|
66
|
+
* versioned cache, so the retry lands on a warm HIT.
|
|
67
|
+
*/
|
|
68
|
+
const MAX_FETCH_ATTEMPTS = 2
|
|
69
|
+
const FETCH_RETRY_BACKOFF_MS = 300
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Retry only transient failures: request timeouts, network errors, and origin
|
|
73
|
+
* 5xx. A 4xx is deterministic (e.g. a genuine 404) — retrying just wastes time.
|
|
74
|
+
*/
|
|
75
|
+
function isRetryableFetchError(error: unknown): boolean {
|
|
76
|
+
if (!(error instanceof WeaverseError)) {
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
if (error.code === 'TIMEOUT_ERROR' || error.code === 'NETWORK_ERROR') {
|
|
80
|
+
return true
|
|
81
|
+
}
|
|
82
|
+
return error.code === 'FETCH_ERROR' && (error.statusCode ?? 0) >= 500
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Weaverse API version path
|
|
87
|
+
*/
|
|
88
|
+
const API_PATH = 'v1' as const
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Default cache strategy for Weaverse API requests.
|
|
92
|
+
* Optimized for CDN performance with 23-hour stale content tolerance.
|
|
93
|
+
*/
|
|
94
|
+
const DEFAULT_CACHE_STRATEGY: CachingStrategy = {
|
|
95
|
+
maxAge: CACHE_DURATIONS.SHORT,
|
|
96
|
+
sMaxAge: CACHE_DURATIONS.SHARED_SHORT,
|
|
97
|
+
staleWhileRevalidate: CACHE_DURATIONS.LONG,
|
|
98
|
+
staleIfError: CACHE_DURATIONS.LONG,
|
|
99
|
+
} as const
|
|
100
|
+
|
|
101
|
+
/** Builder API resource whose freshness policy should be used for a request. */
|
|
102
|
+
export type BuilderApiCacheTarget =
|
|
103
|
+
| 'custom-pages'
|
|
104
|
+
| 'merchant-overrides'
|
|
105
|
+
| 'project'
|
|
106
|
+
| 'theme-settings'
|
|
107
|
+
|
|
108
|
+
/** Fetch options accepted by {@link WeaverseClient.fetchWithCache}. */
|
|
109
|
+
export interface WeaverseFetchWithCacheOptions extends RequestInit {
|
|
110
|
+
/** Builder API resource whose default freshness policy should be used. */
|
|
111
|
+
cacheTarget?: BuilderApiCacheTarget
|
|
112
|
+
/** Explicit Hydrogen caching strategy overriding the resource default. */
|
|
113
|
+
strategy?: CachingStrategy
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Pagination and locale options for {@link WeaverseClient.fetchCustomPages}. */
|
|
117
|
+
export interface FetchCustomPagesOptions {
|
|
118
|
+
/** Maximum number of custom pages requested per API page. */
|
|
119
|
+
limit?: number
|
|
120
|
+
/** Locale whose published custom pages should be returned. */
|
|
121
|
+
locale?: string
|
|
122
|
+
}
|
|
123
|
+
|
|
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
|
+
/**
|
|
137
|
+
* Request-scoped client for loading Weaverse pages and theme settings in Hydrogen.
|
|
138
|
+
*
|
|
139
|
+
* Create one client from the Hydrogen app context, then call
|
|
140
|
+
* {@link WeaverseClient.loadPage} from route loaders and
|
|
141
|
+
* {@link WeaverseClient.loadThemeSettings} from the root loader.
|
|
142
|
+
*/
|
|
143
|
+
export class WeaverseClient {
|
|
144
|
+
/** Builder API version used by this client. */
|
|
145
|
+
API = API_PATH
|
|
146
|
+
/** Stable project configuration shared by page requests. */
|
|
147
|
+
basePageConfigs: Omit<WeaverseProjectConfigs, 'requestInfo'>
|
|
148
|
+
/** Stable request body fields shared by page requests. */
|
|
149
|
+
basePageRequestBody: Omit<FetchProjectRequestBody, 'url'>
|
|
150
|
+
/** Resolved project and rendering-mode configuration. */
|
|
151
|
+
configs: WeaverseProjectConfigs
|
|
152
|
+
/** Hydrogen cache wrapper used for Builder API requests. */
|
|
153
|
+
withCache: ReturnType<typeof createWithCache>
|
|
154
|
+
|
|
155
|
+
/** Incoming request associated with this client instance. */
|
|
156
|
+
request: WeaverseClientArgs['request']
|
|
157
|
+
/** Hydrogen customer-account client, when configured. */
|
|
158
|
+
customerAccount: WeaverseClientArgs['customerAccount']
|
|
159
|
+
/** Hydrogen Storefront API client and locale context. */
|
|
160
|
+
storefront: WeaverseClientArgs['storefront']
|
|
161
|
+
/** Components registered for page rendering and component loaders. */
|
|
162
|
+
components: WeaverseClientArgs['components']
|
|
163
|
+
/** Theme schema used for defaults and Studio editing. */
|
|
164
|
+
themeSchema: WeaverseClientArgs['themeSchema']
|
|
165
|
+
/** Hydrogen runtime environment variables. */
|
|
166
|
+
env: WeaverseClientArgs['env']
|
|
167
|
+
/** Cache storage supplied by the Hydrogen runtime. */
|
|
168
|
+
cache: WeaverseClientArgs['cache']
|
|
169
|
+
/** Background execution hook supplied by the Oxygen runtime. */
|
|
170
|
+
waitUntil: WeaverseClientArgs['waitUntil']
|
|
171
|
+
|
|
172
|
+
// Performance optimizations
|
|
173
|
+
private readonly parsedUrl: URL
|
|
174
|
+
private readonly componentsByType: Map<string, HydrogenComponent>
|
|
175
|
+
|
|
176
|
+
// Configuration
|
|
177
|
+
private readonly fetchTimeoutMs: number
|
|
178
|
+
|
|
179
|
+
/** Creates a request-scoped client from the Hydrogen app context. */
|
|
180
|
+
constructor(args: WeaverseClientArgs) {
|
|
181
|
+
// Assign required dependencies
|
|
182
|
+
this.request = args.request
|
|
183
|
+
this.storefront = args.storefront
|
|
184
|
+
this.customerAccount = args.customerAccount
|
|
185
|
+
this.components = args.components
|
|
186
|
+
this.themeSchema = args.themeSchema
|
|
187
|
+
this.env = args.env
|
|
188
|
+
this.cache = args.cache
|
|
189
|
+
this.waitUntil = args.waitUntil
|
|
190
|
+
|
|
191
|
+
// Performance optimizations
|
|
192
|
+
this.parsedUrl = new URL(args.request.url)
|
|
193
|
+
this.componentsByType = this.buildComponentLookupMap(args.components)
|
|
194
|
+
|
|
195
|
+
// Configuration
|
|
196
|
+
this.fetchTimeoutMs = args.fetchTimeoutMs || DEFAULT_FETCH_TIMEOUT_MS
|
|
197
|
+
|
|
198
|
+
// Initialize cache and configs
|
|
199
|
+
this.initializeCache(args)
|
|
200
|
+
this.initializeConfigs(args)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Initialize Hydrogen cache with proper configuration.
|
|
205
|
+
* Logs warning if waitUntil is missing.
|
|
206
|
+
*/
|
|
207
|
+
private initializeCache(args: WeaverseClientArgs): void {
|
|
208
|
+
if (!args.waitUntil) {
|
|
209
|
+
console.warn(
|
|
210
|
+
'⚠️ WeaverseClient: waitUntil not provided, background operations may not complete'
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
this.withCache = createWithCache({
|
|
215
|
+
cache: args.cache,
|
|
216
|
+
waitUntil: args.waitUntil || (() => Promise.resolve()),
|
|
217
|
+
request: args.request,
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Initialize configs with projectId resolution and fail-fast validation.
|
|
223
|
+
* Throws WeaverseError if projectId cannot be resolved.
|
|
224
|
+
*/
|
|
225
|
+
private initializeConfigs(args: WeaverseClientArgs): void {
|
|
226
|
+
// Get base configs once to avoid duplicate calls
|
|
227
|
+
const baseConfigs = getWeaverseConfigs(
|
|
228
|
+
args.request,
|
|
229
|
+
args.env as HydrogenEnv
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
// Resolve projectId with multi-project support
|
|
233
|
+
const resolvedProjectId = this.resolveProjectIdSync(
|
|
234
|
+
args.projectId,
|
|
235
|
+
args.request,
|
|
236
|
+
args.env as HydrogenEnv,
|
|
237
|
+
baseConfigs
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
// Fail-fast validation: throw if projectId is empty
|
|
241
|
+
if (!resolvedProjectId || resolvedProjectId.trim() === '') {
|
|
242
|
+
throw new WeaverseError(
|
|
243
|
+
'Failed to initialize WeaverseClient: No valid projectId found. ' +
|
|
244
|
+
'Set WEAVERSE_PROJECT_ID environment variable or provide projectId parameter.',
|
|
245
|
+
'MISSING_PROJECT_ID'
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Initialize configs with resolved projectId
|
|
250
|
+
this.configs = {
|
|
251
|
+
...baseConfigs,
|
|
252
|
+
projectId: resolvedProjectId,
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Initialize base configurations
|
|
256
|
+
this.basePageConfigs = {
|
|
257
|
+
projectId: this.configs.projectId,
|
|
258
|
+
weaverseHost: this.configs.weaverseHost,
|
|
259
|
+
weaverseApiBase: this.configs.weaverseApiBase,
|
|
260
|
+
weaverseApiKey: this.configs.weaverseApiKey,
|
|
261
|
+
weaverseVersion: this.configs.weaverseVersion,
|
|
262
|
+
isDesignMode: this.configs.isDesignMode,
|
|
263
|
+
isPreviewMode: this.configs.isPreviewMode,
|
|
264
|
+
isRevisionPreview: this.configs.isRevisionPreview,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
this.basePageRequestBody = {
|
|
268
|
+
isDesignMode: this.configs.isDesignMode,
|
|
269
|
+
i18n: args.storefront.i18n,
|
|
270
|
+
projectId: this.configs.projectId,
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Freeze configs to prevent accidental mutations
|
|
274
|
+
Object.freeze(this.configs)
|
|
275
|
+
Object.freeze(this.basePageConfigs)
|
|
276
|
+
Object.freeze(this.basePageRequestBody)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Build component lookup map for O(1) access by type.
|
|
281
|
+
* Significantly improves performance for large component lists.
|
|
282
|
+
*/
|
|
283
|
+
private buildComponentLookupMap(
|
|
284
|
+
components: HydrogenComponent[]
|
|
285
|
+
): Map<string, HydrogenComponent> {
|
|
286
|
+
return new Map(components.map((comp) => [comp.schema?.type, comp]))
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Extract comprehensive error information from unknown error type.
|
|
291
|
+
* Preserves error context, codes, and stack traces for debugging.
|
|
292
|
+
*
|
|
293
|
+
* @param error - Unknown error to extract information from
|
|
294
|
+
* @returns Structured error information with message, code, stack, and context
|
|
295
|
+
*
|
|
296
|
+
* @example
|
|
297
|
+
* ```typescript
|
|
298
|
+
* try {
|
|
299
|
+
* // ... operation
|
|
300
|
+
* } catch (error) {
|
|
301
|
+
* const errorInfo = this.extractErrorInfo(error)
|
|
302
|
+
* console.error('Operation failed:', errorInfo.message, errorInfo.context)
|
|
303
|
+
* }
|
|
304
|
+
* ```
|
|
305
|
+
*/
|
|
306
|
+
private extractErrorInfo(error: unknown): {
|
|
307
|
+
message: string
|
|
308
|
+
code?: string
|
|
309
|
+
stack?: string
|
|
310
|
+
context?: Record<string, unknown>
|
|
311
|
+
} {
|
|
312
|
+
if (error instanceof WeaverseError) {
|
|
313
|
+
return {
|
|
314
|
+
message: error.message,
|
|
315
|
+
code: error.code,
|
|
316
|
+
context: error.context,
|
|
317
|
+
stack: error.stack,
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (error instanceof Error) {
|
|
322
|
+
return {
|
|
323
|
+
message: error.message,
|
|
324
|
+
stack: error.stack,
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
message: String(error),
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Extract error message only (for backward compatibility).
|
|
335
|
+
* Use extractErrorInfo() for more detailed error information.
|
|
336
|
+
*
|
|
337
|
+
* @deprecated Use extractErrorInfo() instead to preserve error context
|
|
338
|
+
*/
|
|
339
|
+
private extractErrorMessage(error: unknown): string {
|
|
340
|
+
return this.extractErrorInfo(error).message
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Validates projectId format (alphanumeric, hyphens, underscores only).
|
|
345
|
+
* Returns detailed validation result with error message.
|
|
346
|
+
*
|
|
347
|
+
* @param projectId - Project identifier to validate
|
|
348
|
+
* @returns Validation result with error message if invalid
|
|
349
|
+
*/
|
|
350
|
+
private validateProjectId(projectId: string): ProjectIdValidationResult {
|
|
351
|
+
if (!projectId || projectId.trim() === '') {
|
|
352
|
+
return {
|
|
353
|
+
valid: false,
|
|
354
|
+
error: 'Project ID cannot be empty',
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Only allow alphanumeric characters, hyphens, and underscores
|
|
359
|
+
const validPattern = /^[a-zA-Z0-9_-]+$/
|
|
360
|
+
if (!validPattern.test(projectId)) {
|
|
361
|
+
return {
|
|
362
|
+
valid: false,
|
|
363
|
+
error: `Invalid format: "${projectId}". Only alphanumeric characters, hyphens, and underscores are allowed.`,
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return { valid: true }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Resolves projectId with multi-project architecture support.
|
|
372
|
+
* Uses priority chain to determine the correct project ID.
|
|
373
|
+
*
|
|
374
|
+
* @param projectId - Project identifier (string, function, or undefined)
|
|
375
|
+
* @param request - HTTP request for context access
|
|
376
|
+
* @param env - Hydrogen environment with variables
|
|
377
|
+
* @param baseConfigs - Pre-fetched base configs to avoid duplicate calls
|
|
378
|
+
* @returns Resolved project ID string (empty string if none found)
|
|
379
|
+
*/
|
|
380
|
+
private resolveProjectIdSync(
|
|
381
|
+
projectId: string | (() => string) | (() => Promise<string>) | undefined,
|
|
382
|
+
request: Request,
|
|
383
|
+
env: HydrogenEnv,
|
|
384
|
+
baseConfigs?: WeaverseProjectConfigs
|
|
385
|
+
): string {
|
|
386
|
+
return (
|
|
387
|
+
this.resolveFromQueryParams(request) ||
|
|
388
|
+
this.resolveFromFunction(projectId) ||
|
|
389
|
+
this.resolveFromString(projectId) ||
|
|
390
|
+
this.resolveFromEnvironment(baseConfigs, request, env) ||
|
|
391
|
+
''
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Priority 1: Resolve projectId from URL query parameters.
|
|
397
|
+
* Used for design mode and preview mode overrides.
|
|
398
|
+
*/
|
|
399
|
+
private resolveFromQueryParams(request: Request): string | null {
|
|
400
|
+
const queries = getRequestQueries<WeaverseStudioQueries>(request)
|
|
401
|
+
return queries.weaverseProjectId || null
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Priority 2: Resolve projectId from function parameter.
|
|
406
|
+
* Detects and rejects async functions with helpful error.
|
|
407
|
+
*/
|
|
408
|
+
private resolveFromFunction(
|
|
409
|
+
projectId: string | (() => string) | (() => Promise<string>) | undefined
|
|
410
|
+
): string | null {
|
|
411
|
+
if (!isProjectIdFunction(projectId)) {
|
|
412
|
+
return null
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Check if it's an async function BEFORE calling
|
|
416
|
+
if (projectId.constructor.name === 'AsyncFunction') {
|
|
417
|
+
throw new WeaverseError(
|
|
418
|
+
'Async projectId functions must be awaited before passing to WeaverseClient. ' +
|
|
419
|
+
'Use: projectId: await getProjectAsync() instead of: projectId: getProjectAsync',
|
|
420
|
+
'ASYNC_PROJECT_ID'
|
|
421
|
+
)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
try {
|
|
425
|
+
const result = projectId()
|
|
426
|
+
|
|
427
|
+
// Safety net: check if result is a Promise
|
|
428
|
+
if (result instanceof Promise) {
|
|
429
|
+
throw new WeaverseError(
|
|
430
|
+
'Async projectId functions must be awaited before passing to WeaverseClient. ' +
|
|
431
|
+
'Use: projectId: await getProjectAsync() instead of: projectId: getProjectAsync',
|
|
432
|
+
'ASYNC_PROJECT_ID'
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (result && result.trim() !== '') {
|
|
437
|
+
const trimmed = result.trim()
|
|
438
|
+
const validation = this.validateProjectId(trimmed)
|
|
439
|
+
|
|
440
|
+
if (validation.valid) {
|
|
441
|
+
return trimmed
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Throw on validation failure - fail-fast for explicit projectId
|
|
445
|
+
throw new WeaverseError(
|
|
446
|
+
validation.error || 'Invalid projectId format',
|
|
447
|
+
'INVALID_PROJECT_ID'
|
|
448
|
+
)
|
|
449
|
+
}
|
|
450
|
+
} catch (err) {
|
|
451
|
+
// Re-throw WeaverseError (async function errors, validation errors)
|
|
452
|
+
if (err instanceof WeaverseError) {
|
|
453
|
+
throw err
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Wrap unexpected errors
|
|
457
|
+
throw new WeaverseError(
|
|
458
|
+
`Failed to evaluate projectId function: ${err instanceof Error ? err.message : String(err)}`,
|
|
459
|
+
'PROJECT_ID_FUNCTION_ERROR',
|
|
460
|
+
undefined,
|
|
461
|
+
{ cause: err instanceof Error ? err.stack : String(err) }
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return null
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Priority 3: Resolve projectId from explicit string parameter.
|
|
470
|
+
*/
|
|
471
|
+
private resolveFromString(
|
|
472
|
+
projectId: string | (() => string) | (() => Promise<string>) | undefined
|
|
473
|
+
): string | null {
|
|
474
|
+
if (typeof projectId !== 'string') {
|
|
475
|
+
return null
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const trimmed = projectId.trim()
|
|
479
|
+
if (trimmed === '') {
|
|
480
|
+
return null
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const validation = this.validateProjectId(trimmed)
|
|
484
|
+
if (validation.valid) {
|
|
485
|
+
return trimmed
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Throw on validation failure - fail-fast for explicit projectId
|
|
489
|
+
throw new WeaverseError(
|
|
490
|
+
validation.error || 'Invalid projectId format',
|
|
491
|
+
'INVALID_PROJECT_ID'
|
|
492
|
+
)
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Priority 4: Resolve projectId from environment variables.
|
|
497
|
+
*/
|
|
498
|
+
private resolveFromEnvironment(
|
|
499
|
+
baseConfigs: WeaverseProjectConfigs | undefined,
|
|
500
|
+
request: Request,
|
|
501
|
+
env: HydrogenEnv
|
|
502
|
+
): string | null {
|
|
503
|
+
const configs = baseConfigs || getWeaverseConfigs(request, env)
|
|
504
|
+
|
|
505
|
+
if (configs.projectId && configs.projectId.trim() !== '') {
|
|
506
|
+
return configs.projectId.trim()
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return null
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Helper method for direct fetching in design mode.
|
|
514
|
+
* Returns same format as withCache.fetch for consistency.
|
|
515
|
+
* Includes 10-second timeout to prevent hanging requests.
|
|
516
|
+
*/
|
|
517
|
+
private readonly fetchOnce = async <T>(
|
|
518
|
+
url: string,
|
|
519
|
+
options: RequestInit,
|
|
520
|
+
timeoutMs: number
|
|
521
|
+
): Promise<WithCacheFetchResponse<T>> => {
|
|
522
|
+
const controller = new AbortController()
|
|
523
|
+
const timeoutId: ReturnType<typeof setTimeout> | undefined = setTimeout(
|
|
524
|
+
() => controller.abort(),
|
|
525
|
+
timeoutMs
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
try {
|
|
529
|
+
const response = await fetch(url, {
|
|
530
|
+
...options,
|
|
531
|
+
signal: controller.signal,
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
if (!response.ok) {
|
|
535
|
+
const errorText = await response.text()
|
|
536
|
+
throw new WeaverseError(
|
|
537
|
+
`HTTP ${response.status}: ${response.statusText}`,
|
|
538
|
+
'FETCH_ERROR',
|
|
539
|
+
response.status,
|
|
540
|
+
{ url, errorText }
|
|
541
|
+
)
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const data = (await response.json()) as T
|
|
545
|
+
return { data, response }
|
|
546
|
+
} catch (err) {
|
|
547
|
+
if (err instanceof WeaverseError) {
|
|
548
|
+
throw err
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (err.name === 'AbortError') {
|
|
552
|
+
throw new WeaverseError(
|
|
553
|
+
`Request timeout after ${timeoutMs}ms`,
|
|
554
|
+
'TIMEOUT_ERROR',
|
|
555
|
+
undefined,
|
|
556
|
+
{ url, timeoutMs }
|
|
557
|
+
)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
throw new WeaverseError(
|
|
561
|
+
'Network error during fetch',
|
|
562
|
+
'NETWORK_ERROR',
|
|
563
|
+
undefined,
|
|
564
|
+
{ url, cause: err instanceof Error ? err.message : String(err) }
|
|
565
|
+
)
|
|
566
|
+
} finally {
|
|
567
|
+
if (timeoutId !== undefined) {
|
|
568
|
+
clearTimeout(timeoutId)
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
private readonly directFetch = async <T>(
|
|
574
|
+
url: string,
|
|
575
|
+
options: RequestInit = {},
|
|
576
|
+
timeoutMs = this.fetchTimeoutMs
|
|
577
|
+
): Promise<WithCacheFetchResponse<T>> => {
|
|
578
|
+
let lastError: unknown
|
|
579
|
+
for (let attempt = 1; attempt <= MAX_FETCH_ATTEMPTS; attempt++) {
|
|
580
|
+
try {
|
|
581
|
+
return await this.fetchOnce<T>(url, options, timeoutMs)
|
|
582
|
+
} catch (err) {
|
|
583
|
+
lastError = err
|
|
584
|
+
if (attempt >= MAX_FETCH_ATTEMPTS || !isRetryableFetchError(err)) {
|
|
585
|
+
throw err
|
|
586
|
+
}
|
|
587
|
+
await new Promise((resolve) =>
|
|
588
|
+
setTimeout(resolve, FETCH_RETRY_BACKOFF_MS * attempt)
|
|
589
|
+
)
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
throw lastError
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Helper method for building API URLs consistently
|
|
596
|
+
private getApiUrl(endpoint: string): string {
|
|
597
|
+
const { weaverseApiBase } = this.configs
|
|
598
|
+
return `${weaverseApiBase}/api/public/${endpoint}`
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private isExternalPublicApiUrl(url: string): boolean {
|
|
602
|
+
const { weaverseApiBase, weaverseHost } = this.configs
|
|
603
|
+
return (
|
|
604
|
+
weaverseApiBase !== weaverseHost &&
|
|
605
|
+
url.startsWith(`${weaverseApiBase}/api/public/`)
|
|
606
|
+
)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Fetch data with caching support for both design mode and production.
|
|
611
|
+
* Uses direct fetch in design mode, Hydrogen's withCache in production.
|
|
612
|
+
* Automatically includes projectId in cache key for multi-project isolation.
|
|
613
|
+
*
|
|
614
|
+
* @param url - The URL to fetch from
|
|
615
|
+
* @param options - Fetch options including optional cache strategy
|
|
616
|
+
* @returns Promise resolving to the fetched data
|
|
617
|
+
*
|
|
618
|
+
* @example
|
|
619
|
+
* ```typescript
|
|
620
|
+
* const data = await weaverse.fetchWithCache<MyData>(
|
|
621
|
+
* 'https://api.example.com/data',
|
|
622
|
+
* { method: 'POST', body: JSON.stringify({ key: 'value' }) }
|
|
623
|
+
* )
|
|
624
|
+
* ```
|
|
625
|
+
*/
|
|
626
|
+
public fetchWithCache = async <T = unknown>(
|
|
627
|
+
url: string,
|
|
628
|
+
options: WeaverseFetchWithCacheOptions = {}
|
|
629
|
+
): Promise<T> => {
|
|
630
|
+
const { cacheTarget, strategy: strategyOverride, ...fetchOptions } = options
|
|
631
|
+
const strategy =
|
|
632
|
+
strategyOverride ||
|
|
633
|
+
CacheCustom(
|
|
634
|
+
cacheTarget
|
|
635
|
+
? SMART_CACHE_STRATEGIES[cacheTarget]
|
|
636
|
+
: DEFAULT_CACHE_STRATEGY
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
// Update cache key to include method, body content, and projectId for multi-project isolation
|
|
640
|
+
// Prefix for easier debugging in cache systems
|
|
641
|
+
const cacheKey = [
|
|
642
|
+
'weaverse-fetch',
|
|
643
|
+
url,
|
|
644
|
+
options.method || 'GET',
|
|
645
|
+
fetchOptions.body,
|
|
646
|
+
this.configs.projectId,
|
|
647
|
+
cacheTarget || 'default',
|
|
648
|
+
]
|
|
649
|
+
|
|
650
|
+
let result: WithCacheFetchResponse<T>
|
|
651
|
+
|
|
652
|
+
// Bypass the shared Hydrogen subrequest cache for design/revision modes
|
|
653
|
+
// and for the Cloudflare public API proxy. The proxy owns freshness with
|
|
654
|
+
// versioned cache keys; keeping Hydrogen's URL/body cache in front would
|
|
655
|
+
// keep serving a stale response even after Builder bumps api.weaverse.io's
|
|
656
|
+
// project version.
|
|
657
|
+
if (
|
|
658
|
+
this.configs.isDesignMode ||
|
|
659
|
+
this.configs.isRevisionPreview ||
|
|
660
|
+
this.isExternalPublicApiUrl(url)
|
|
661
|
+
) {
|
|
662
|
+
result = await this.directFetch<T>(url, fetchOptions)
|
|
663
|
+
} else {
|
|
664
|
+
// Use withCache.fetch for better integration with Hydrogen's caching system
|
|
665
|
+
result = (await this.withCache.fetch(url, fetchOptions, {
|
|
666
|
+
cacheKey,
|
|
667
|
+
cacheStrategy: strategy,
|
|
668
|
+
shouldCacheResponse: (response: any): boolean => {
|
|
669
|
+
// Cache any non-error response (for both Weaverse API and third-party APIs)
|
|
670
|
+
return (
|
|
671
|
+
!hasError(response) && response !== null && response !== undefined
|
|
672
|
+
)
|
|
673
|
+
},
|
|
674
|
+
displayName: 'Weaverse API',
|
|
675
|
+
})) as WithCacheFetchResponse<T>
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return result.data
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Load theme settings from Weaverse API with fallback to schema defaults.
|
|
683
|
+
* Merges remote settings with local theme schema and handles design mode serialization.
|
|
684
|
+
*
|
|
685
|
+
* @param strategy - Optional cache strategy override
|
|
686
|
+
* @returns Promise resolving to theme settings response
|
|
687
|
+
*
|
|
688
|
+
* @example
|
|
689
|
+
* ```typescript
|
|
690
|
+
* const settings = await weaverse.loadThemeSettings()
|
|
691
|
+
* const primaryColor = settings.theme.colors?.primary
|
|
692
|
+
* ```
|
|
693
|
+
*/
|
|
694
|
+
loadThemeSettings = async (
|
|
695
|
+
strategy?: CachingStrategy
|
|
696
|
+
): Promise<ThemeSettingsResponse> => {
|
|
697
|
+
const defaultThemeSettings = generateDataFromSchema(this.themeSchema)
|
|
698
|
+
try {
|
|
699
|
+
const { configs } = this
|
|
700
|
+
const { projectId, isDesignMode } = configs
|
|
701
|
+
if (!projectId) {
|
|
702
|
+
throw new Error('Missing Weaverse projectId!')
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const url = this.getApiUrl('project_configs')
|
|
706
|
+
|
|
707
|
+
const body = JSON.stringify({ isDesignMode, projectId })
|
|
708
|
+
|
|
709
|
+
// Fetch theme settings and merchant overrides in parallel
|
|
710
|
+
const [data, merchantOverrides] = await Promise.all([
|
|
711
|
+
this.fetchWithCache<ThemeSettingsResponse>(url, {
|
|
712
|
+
method: 'POST',
|
|
713
|
+
cacheTarget: 'theme-settings',
|
|
714
|
+
strategy,
|
|
715
|
+
body,
|
|
716
|
+
headers: {
|
|
717
|
+
'Content-Type': 'application/json',
|
|
718
|
+
Accept: 'application/json',
|
|
719
|
+
'Accept-Encoding': 'gzip, deflate, br',
|
|
720
|
+
},
|
|
721
|
+
}),
|
|
722
|
+
this.fetchMerchantOverrides(strategy),
|
|
723
|
+
])
|
|
724
|
+
|
|
725
|
+
let result = data
|
|
726
|
+
|
|
727
|
+
// Validate that data is a proper object, not corrupted/compressed content
|
|
728
|
+
if (typeof result !== 'object' || result === null) {
|
|
729
|
+
console.warn(
|
|
730
|
+
'Invalid theme settings response format, using defaults:',
|
|
731
|
+
{
|
|
732
|
+
type: typeof result,
|
|
733
|
+
isString: typeof result === 'string',
|
|
734
|
+
length:
|
|
735
|
+
typeof result === 'string' ? (result as string).length : 'N/A',
|
|
736
|
+
preview:
|
|
737
|
+
typeof result === 'string'
|
|
738
|
+
? `${(result as string).slice(0, 50)}...`
|
|
739
|
+
: String(result),
|
|
740
|
+
}
|
|
741
|
+
)
|
|
742
|
+
result = { theme: defaultThemeSettings }
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (this.themeSchema?.settings || this.themeSchema?.inspector) {
|
|
746
|
+
// Ensure result.theme exists and is an object before merging
|
|
747
|
+
if (!result.theme || typeof result.theme !== 'object') {
|
|
748
|
+
result.theme = defaultThemeSettings
|
|
749
|
+
} else {
|
|
750
|
+
result.theme = {
|
|
751
|
+
...defaultThemeSettings,
|
|
752
|
+
...result.theme,
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Always include staticContent from themeSchema.i18n
|
|
758
|
+
const staticContent = this.themeSchema?.i18n?.staticContent
|
|
759
|
+
if (staticContent) {
|
|
760
|
+
result.staticContent = staticContent
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Include merchant overrides (locale-specific translations from DB)
|
|
764
|
+
if (merchantOverrides) {
|
|
765
|
+
result.merchantOverrides = merchantOverrides
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (this.configs.isDesignMode) {
|
|
769
|
+
result = {
|
|
770
|
+
...result,
|
|
771
|
+
schema:
|
|
772
|
+
this.themeSchema && typeof this.themeSchema === 'object'
|
|
773
|
+
? {
|
|
774
|
+
...this.themeSchema,
|
|
775
|
+
settings: this.themeSchema?.settings?.map((group) => ({
|
|
776
|
+
...group,
|
|
777
|
+
inputs: group?.inputs?.map((input) => {
|
|
778
|
+
if (typeof input?.condition === 'function') {
|
|
779
|
+
return {
|
|
780
|
+
...input,
|
|
781
|
+
condition: input.condition.toString(),
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return input
|
|
785
|
+
}),
|
|
786
|
+
})),
|
|
787
|
+
}
|
|
788
|
+
: undefined,
|
|
789
|
+
publicEnv: this.configs.publicEnv,
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return result
|
|
793
|
+
} catch (error) {
|
|
794
|
+
const errorMessage = 'Unable to load theme settings'
|
|
795
|
+
const errorDetails = this.extractErrorMessage(error)
|
|
796
|
+
|
|
797
|
+
console.error(errorMessage, errorDetails)
|
|
798
|
+
|
|
799
|
+
return {
|
|
800
|
+
theme: defaultThemeSettings,
|
|
801
|
+
staticContent: this.themeSchema?.i18n?.staticContent,
|
|
802
|
+
_error: errorDetails,
|
|
803
|
+
_loadFailed: true,
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Fetch locale-specific merchant overrides from the translation API.
|
|
810
|
+
* Returns a nested JSON object with translated values.
|
|
811
|
+
* Falls back to empty object on error to avoid blocking theme rendering.
|
|
812
|
+
*/
|
|
813
|
+
private fetchMerchantOverrides = async (
|
|
814
|
+
strategy?: CachingStrategy
|
|
815
|
+
): Promise<Record<string, unknown> | undefined> => {
|
|
816
|
+
try {
|
|
817
|
+
const { projectId, weaverseHost } = this.configs
|
|
818
|
+
const i18n = this.storefront.i18n
|
|
819
|
+
|
|
820
|
+
// Skip entirely when the theme hasn't opted into i18n. Merchant overrides
|
|
821
|
+
// are locale-specific translations; a theme with no `i18n` schema has no
|
|
822
|
+
// translatable surface, so the fetch would always come back empty. Gating
|
|
823
|
+
// here keeps non-i18n storefronts from making a translation API call on
|
|
824
|
+
// every SSR — same `themeSchema.i18n` condition that guards `staticContent`.
|
|
825
|
+
if (!this.themeSchema?.i18n) {
|
|
826
|
+
return
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
if (!(projectId && weaverseHost)) {
|
|
830
|
+
return
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Build locale code from storefront i18n (e.g. "en-us")
|
|
834
|
+
const language = (i18n?.language || 'en').toLowerCase()
|
|
835
|
+
const country = (i18n?.country || 'us').toLowerCase()
|
|
836
|
+
const locale = `${language}-${country}`
|
|
837
|
+
|
|
838
|
+
const url = `${weaverseHost}/api/translation/static?projectId=${projectId}&locale=${locale}`
|
|
839
|
+
|
|
840
|
+
const overrides = await this.fetchWithCache<Record<string, unknown>>(
|
|
841
|
+
url,
|
|
842
|
+
{
|
|
843
|
+
method: 'GET',
|
|
844
|
+
cacheTarget: 'merchant-overrides',
|
|
845
|
+
strategy,
|
|
846
|
+
headers: {
|
|
847
|
+
Accept: 'application/json',
|
|
848
|
+
},
|
|
849
|
+
}
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
// Validate it's a proper object
|
|
853
|
+
if (typeof overrides === 'object' && overrides !== null) {
|
|
854
|
+
return overrides
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
return
|
|
858
|
+
} catch (error) {
|
|
859
|
+
// Don't let merchant overrides failure block theme rendering
|
|
860
|
+
console.warn(
|
|
861
|
+
'Unable to load merchant overrides, using theme defaults:',
|
|
862
|
+
this.extractErrorMessage(error)
|
|
863
|
+
)
|
|
864
|
+
return
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Generate fallback page with default structure when page data is unavailable.
|
|
870
|
+
* Used internally when API returns no page or when page needs default content.
|
|
871
|
+
*
|
|
872
|
+
* @param message - Custom message to display in the fallback page
|
|
873
|
+
* @returns HydrogenPageData with single main component containing the message
|
|
874
|
+
*/
|
|
875
|
+
generateFallbackPage = (message: string): HydrogenPageData => {
|
|
876
|
+
const rootId = crypto.randomUUID()
|
|
877
|
+
return {
|
|
878
|
+
id: `fallback_page_${Date.now()}`,
|
|
879
|
+
rootId,
|
|
880
|
+
name: 'Default Page',
|
|
881
|
+
items: [
|
|
882
|
+
{
|
|
883
|
+
type: 'main',
|
|
884
|
+
id: rootId,
|
|
885
|
+
data: { dangerouslySetInnerHTML: { __html: message } },
|
|
886
|
+
},
|
|
887
|
+
],
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Fetch all published custom pages for sitemap generation.
|
|
893
|
+
* Paginates automatically, never throws, returns accumulated results on partial failure.
|
|
894
|
+
*/
|
|
895
|
+
async fetchCustomPages(
|
|
896
|
+
opts?: FetchCustomPagesOptions
|
|
897
|
+
): Promise<CustomPageEntry[]> {
|
|
898
|
+
const { weaverseApiBase, projectId } = this.configs
|
|
899
|
+
const entries: CustomPageEntry[] = []
|
|
900
|
+
const MAX_PAGES = 100
|
|
901
|
+
|
|
902
|
+
let cursor: string | null = null
|
|
903
|
+
|
|
904
|
+
for (let i = 0; i < MAX_PAGES; i++) {
|
|
905
|
+
const params = new URLSearchParams()
|
|
906
|
+
if (opts?.locale) {
|
|
907
|
+
params.set('locale', opts.locale)
|
|
908
|
+
}
|
|
909
|
+
if (opts?.limit) {
|
|
910
|
+
params.set('limit', String(opts.limit))
|
|
911
|
+
}
|
|
912
|
+
if (cursor) {
|
|
913
|
+
params.set('cursor', cursor)
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
const qs = params.toString()
|
|
917
|
+
const url = `${weaverseApiBase}/api/public/v1/projects/${projectId}/custom-pages${qs ? `?${qs}` : ''}`
|
|
918
|
+
|
|
919
|
+
try {
|
|
920
|
+
const result = await this.fetchWithCache<{
|
|
921
|
+
data: CustomPageEntry[]
|
|
922
|
+
nextCursor: string | null
|
|
923
|
+
}>(url, { cacheTarget: 'custom-pages' })
|
|
924
|
+
entries.push(...(result.data || []))
|
|
925
|
+
|
|
926
|
+
if (!result.nextCursor) {
|
|
927
|
+
return entries
|
|
928
|
+
}
|
|
929
|
+
cursor = result.nextCursor
|
|
930
|
+
} catch (err: any) {
|
|
931
|
+
const status = err?.status
|
|
932
|
+
const msg = err?.message || String(err)
|
|
933
|
+
console.warn(
|
|
934
|
+
entries.length === 0
|
|
935
|
+
? `[Weaverse] Failed to fetch custom pages: ${msg}${status ? ` (HTTP ${status})` : ''}`
|
|
936
|
+
: `[Weaverse] Custom pages pagination interrupted: ${msg}`
|
|
937
|
+
)
|
|
938
|
+
return entries
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
console.warn(
|
|
943
|
+
'[Weaverse] Custom pages pagination cap reached (100 iterations)'
|
|
944
|
+
)
|
|
945
|
+
return entries
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Load page data from Weaverse API with optional route-level project override.
|
|
950
|
+
* Fetches page content, executes component loaders, and handles empty items fallback.
|
|
951
|
+
*
|
|
952
|
+
* @param params - Optional page loading parameters
|
|
953
|
+
* @param params.type - Page type (INDEX, PAGE, PRODUCT, COLLECTION, etc.)
|
|
954
|
+
* @param params.handle - Page handle for dynamic routes
|
|
955
|
+
* @param params.locale - Locale override
|
|
956
|
+
* @param params.projectId - Route-level project ID override (for multi-project)
|
|
957
|
+
* @param params.strategy - Custom cache strategy override
|
|
958
|
+
* @returns Promise resolving to page data or null on error
|
|
959
|
+
*
|
|
960
|
+
* @example
|
|
961
|
+
* ```typescript
|
|
962
|
+
* // Basic usage
|
|
963
|
+
* const data = await weaverse.loadPage()
|
|
964
|
+
*
|
|
965
|
+
* // With page type
|
|
966
|
+
* const data = await weaverse.loadPage({ type: 'INDEX' })
|
|
967
|
+
*
|
|
968
|
+
* // Route-level project override
|
|
969
|
+
* const data = await weaverse.loadPage({
|
|
970
|
+
* type: 'PAGE',
|
|
971
|
+
* handle: 'about',
|
|
972
|
+
* projectId: 'campaign-project-123'
|
|
973
|
+
* })
|
|
974
|
+
* ```
|
|
975
|
+
*/
|
|
976
|
+
loadPage = async (
|
|
977
|
+
params: LoadPageParams = {}
|
|
978
|
+
): Promise<WeaverseLoaderData | null> => {
|
|
979
|
+
try {
|
|
980
|
+
const { request, storefront, basePageRequestBody, basePageConfigs } = this
|
|
981
|
+
const {
|
|
982
|
+
projectId: clientProjectId,
|
|
983
|
+
weaverseHost,
|
|
984
|
+
isPreviewMode,
|
|
985
|
+
sectionType,
|
|
986
|
+
} = this.configs
|
|
987
|
+
|
|
988
|
+
// Validate route-level projectId if provided
|
|
989
|
+
if (params.projectId !== undefined) {
|
|
990
|
+
if (typeof params.projectId !== 'string') {
|
|
991
|
+
console.error(
|
|
992
|
+
'Route-level projectId validation failed: must be a string. ' +
|
|
993
|
+
`Received: ${typeof params.projectId}`
|
|
994
|
+
)
|
|
995
|
+
return null
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const validation = this.validateProjectId(params.projectId)
|
|
999
|
+
if (!validation.valid) {
|
|
1000
|
+
console.error(
|
|
1001
|
+
`Route-level projectId validation failed: ${validation.error}`
|
|
1002
|
+
)
|
|
1003
|
+
return null
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Override client-level projectId with route-level projectId if provided
|
|
1008
|
+
const effectiveProjectId = params.projectId || clientProjectId
|
|
1009
|
+
|
|
1010
|
+
if (isPreviewMode) {
|
|
1011
|
+
return getPreviewData(sectionType || '', this.components, weaverseHost)
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
if (!effectiveProjectId) {
|
|
1015
|
+
throw new Error('Missing Weaverse projectId!')
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const { strategy, ...pageLoadParams } = params
|
|
1019
|
+
const body: {
|
|
1020
|
+
projectId: string
|
|
1021
|
+
url: string
|
|
1022
|
+
i18n?: I18nBase
|
|
1023
|
+
params?: {
|
|
1024
|
+
type?: PageType
|
|
1025
|
+
locale?: string
|
|
1026
|
+
handle?: string
|
|
1027
|
+
}
|
|
1028
|
+
isDesignMode?: boolean
|
|
1029
|
+
} = {
|
|
1030
|
+
...basePageRequestBody,
|
|
1031
|
+
projectId: effectiveProjectId, // Use effective (potentially overridden) projectId
|
|
1032
|
+
params: pageLoadParams,
|
|
1033
|
+
// Stripped to pathname + Weaverse params — tracking params would
|
|
1034
|
+
// fragment the subrequest cache key (see normalizePageUrl).
|
|
1035
|
+
url: normalizePageUrl(request.url),
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
const reqInit: RequestInit = {
|
|
1039
|
+
method: 'POST',
|
|
1040
|
+
body: JSON.stringify(body),
|
|
1041
|
+
headers: {
|
|
1042
|
+
'Content-Type': 'application/json',
|
|
1043
|
+
Accept: 'application/json',
|
|
1044
|
+
'Accept-Encoding': 'gzip, deflate, br',
|
|
1045
|
+
'X-Visitor-UA': request.headers.get('user-agent') || '',
|
|
1046
|
+
},
|
|
1047
|
+
}
|
|
1048
|
+
const url = this.getApiUrl('project')
|
|
1049
|
+
|
|
1050
|
+
const projectData: FetchProjectPayload =
|
|
1051
|
+
await this.fetchWithCache<FetchProjectPayload>(url, {
|
|
1052
|
+
...reqInit,
|
|
1053
|
+
cacheTarget: 'project',
|
|
1054
|
+
strategy,
|
|
1055
|
+
})
|
|
1056
|
+
|
|
1057
|
+
if (hasError(projectData)) {
|
|
1058
|
+
throw new Error(projectData.error)
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// Extract what we can from the response using type guard
|
|
1062
|
+
// If validation fails, use fallback data instead of throwing
|
|
1063
|
+
let page: HydrogenPageData | undefined
|
|
1064
|
+
let project: WeaverseLoaderData['project'] | undefined
|
|
1065
|
+
let pageAssignment: WeaverseLoaderData['pageAssignment'] | undefined
|
|
1066
|
+
|
|
1067
|
+
if (isFetchProjectPayload(projectData)) {
|
|
1068
|
+
// Valid response - extract normally
|
|
1069
|
+
page = projectData.page
|
|
1070
|
+
project = projectData.project
|
|
1071
|
+
pageAssignment = projectData.pageAssignment
|
|
1072
|
+
} else {
|
|
1073
|
+
console.warn(
|
|
1074
|
+
'Invalid project data structure received from API, using fallback data',
|
|
1075
|
+
{ projectId: effectiveProjectId }
|
|
1076
|
+
)
|
|
1077
|
+
// Try to extract what we can from the malformed response
|
|
1078
|
+
const responseData = projectData as any
|
|
1079
|
+
page = responseData?.page
|
|
1080
|
+
project = responseData?.project
|
|
1081
|
+
pageAssignment = responseData?.pageAssignment
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (!project) {
|
|
1085
|
+
console.warn('Weaverse project not found. Id:', effectiveProjectId)
|
|
1086
|
+
project = {
|
|
1087
|
+
id: effectiveProjectId,
|
|
1088
|
+
name: 'Weaverse project not found.',
|
|
1089
|
+
weaverseShopId: '',
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
if (!page) {
|
|
1094
|
+
page = this.generateFallbackPage(
|
|
1095
|
+
'<div style="text-align: center;">Please add new section to start.</div>'
|
|
1096
|
+
)
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
if (page?.items?.length === 0) {
|
|
1100
|
+
page.items.push({
|
|
1101
|
+
type: 'main',
|
|
1102
|
+
id: page.rootId || crypto.randomUUID(),
|
|
1103
|
+
data: {},
|
|
1104
|
+
})
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
if (page?.items) {
|
|
1108
|
+
const items = page.items
|
|
1109
|
+
page.items = await Promise.all(items.map(this.execComponentLoader))
|
|
1110
|
+
}
|
|
1111
|
+
return {
|
|
1112
|
+
page,
|
|
1113
|
+
project,
|
|
1114
|
+
pageAssignment,
|
|
1115
|
+
configs: {
|
|
1116
|
+
...basePageConfigs,
|
|
1117
|
+
requestInfo: {
|
|
1118
|
+
i18n: storefront.i18n,
|
|
1119
|
+
queries: getRequestQueries<WeaverseStudioQueries>(request),
|
|
1120
|
+
pathname: this.parsedUrl.pathname,
|
|
1121
|
+
search: this.parsedUrl.search,
|
|
1122
|
+
},
|
|
1123
|
+
},
|
|
1124
|
+
}
|
|
1125
|
+
} catch (error) {
|
|
1126
|
+
const errorMessage = '❌ Page load failed'
|
|
1127
|
+
const errorContext: Record<string, unknown> = {
|
|
1128
|
+
url: this.request.url,
|
|
1129
|
+
projectId: params.projectId || this.configs.projectId,
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
if (error instanceof WeaverseError) {
|
|
1133
|
+
console.error(errorMessage, {
|
|
1134
|
+
code: error.code,
|
|
1135
|
+
message: error.message,
|
|
1136
|
+
...errorContext,
|
|
1137
|
+
...error.context,
|
|
1138
|
+
})
|
|
1139
|
+
} else if (error instanceof Error) {
|
|
1140
|
+
console.error(errorMessage, {
|
|
1141
|
+
message: error.message,
|
|
1142
|
+
stack: error.stack,
|
|
1143
|
+
...errorContext,
|
|
1144
|
+
})
|
|
1145
|
+
} else {
|
|
1146
|
+
console.error(errorMessage, {
|
|
1147
|
+
error: String(error),
|
|
1148
|
+
...errorContext,
|
|
1149
|
+
})
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
return null
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/** Executes the registered loader for one component and attaches its result. */
|
|
1157
|
+
execComponentLoader = async (item: HydrogenComponentData) => {
|
|
1158
|
+
const { data = {}, type, id } = item
|
|
1159
|
+
const component = this.componentsByType.get(type)
|
|
1160
|
+
const loader = component?.loader
|
|
1161
|
+
if (typeof loader === 'function' && component) {
|
|
1162
|
+
try {
|
|
1163
|
+
return {
|
|
1164
|
+
...item,
|
|
1165
|
+
loaderData: await Promise.resolve(
|
|
1166
|
+
loader({
|
|
1167
|
+
data: { ...generateDataFromSchema(component.schema), ...data },
|
|
1168
|
+
weaverse: this,
|
|
1169
|
+
})
|
|
1170
|
+
),
|
|
1171
|
+
}
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
console.warn('❌ Item loader run failed.', type, id, error)
|
|
1174
|
+
return item
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
return item
|
|
1178
|
+
}
|
|
1179
|
+
}
|