@weaverse/hydrogen 5.17.0 → 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.
Files changed (70) hide show
  1. package/dist/index.js +83 -1
  2. package/dist/index.js.map +1 -1
  3. package/dist/index.mjs +83 -1
  4. package/dist/index.mjs.map +1 -1
  5. package/dist/types/WeaverseHydrogenRoot.d.ts +218 -0
  6. package/dist/types/WeaverseHydrogenRoot.d.ts.map +1 -0
  7. package/dist/types/components/index.d.ts +4 -0
  8. package/dist/types/components/index.d.ts.map +1 -0
  9. package/dist/types/components/main.d.ts +7 -0
  10. package/dist/types/components/main.d.ts.map +1 -0
  11. package/dist/types/hooks/theme-text-context.d.ts +2 -0
  12. package/dist/types/hooks/theme-text-context.d.ts.map +1 -0
  13. package/dist/types/hooks/translation-context.d.ts +138 -0
  14. package/dist/types/hooks/translation-context.d.ts.map +1 -0
  15. package/dist/types/hooks/use-weaverse-data-context.d.ts +29 -0
  16. package/dist/types/hooks/use-weaverse-data-context.d.ts.map +1 -0
  17. package/dist/types/index.d.ts +99 -0
  18. package/dist/types/index.d.ts.map +1 -0
  19. package/dist/types/seo.d.ts +43 -0
  20. package/dist/types/seo.d.ts.map +1 -0
  21. package/dist/types/types.d.ts +826 -0
  22. package/dist/types/types.d.ts.map +1 -0
  23. package/dist/types/utils/index.d.ts +44 -0
  24. package/dist/types/utils/index.d.ts.map +1 -0
  25. package/dist/types/utils/normalize-page-url.d.ts +16 -0
  26. package/dist/types/utils/normalize-page-url.d.ts.map +1 -0
  27. package/dist/types/utils/pick-weaverse-data.d.ts +44 -0
  28. package/dist/types/utils/pick-weaverse-data.d.ts.map +1 -0
  29. package/dist/types/utils/pixel.d.ts +16 -0
  30. package/dist/types/utils/pixel.d.ts.map +1 -0
  31. package/dist/types/utils/studio-request-info.d.ts +17 -0
  32. package/dist/types/utils/studio-request-info.d.ts.map +1 -0
  33. package/dist/types/utils/studio-script-src.d.ts +78 -0
  34. package/dist/types/utils/studio-script-src.d.ts.map +1 -0
  35. package/dist/types/utils/sync-reused-instance.d.ts +21 -0
  36. package/dist/types/utils/sync-reused-instance.d.ts.map +1 -0
  37. package/dist/types/utils/theme-text-store.d.ts +2 -0
  38. package/dist/types/utils/theme-text-store.d.ts.map +1 -0
  39. package/dist/types/utils/translation-store.d.ts +40 -0
  40. package/dist/types/utils/translation-store.d.ts.map +1 -0
  41. package/dist/types/utils/use-studio.d.ts +33 -0
  42. package/dist/types/utils/use-studio.d.ts.map +1 -0
  43. package/dist/types/utils/use-theme-settings-store.d.ts +39 -0
  44. package/dist/types/utils/use-theme-settings-store.d.ts.map +1 -0
  45. package/dist/types/weaverse-client.d.ts +227 -0
  46. package/dist/types/weaverse-client.d.ts.map +1 -0
  47. package/package.json +7 -6
  48. package/src/WeaverseHydrogenRoot.tsx +649 -0
  49. package/src/components/index.ts +10 -0
  50. package/src/components/main.tsx +28 -0
  51. package/src/hooks/theme-text-context.tsx +14 -0
  52. package/src/hooks/translation-context.tsx +275 -0
  53. package/src/hooks/use-weaverse-data-context.ts +69 -0
  54. package/src/index.ts +179 -0
  55. package/src/seo.ts +125 -0
  56. package/src/types.ts +1020 -0
  57. package/src/utils/index.ts +351 -0
  58. package/src/utils/normalize-page-url.ts +44 -0
  59. package/src/utils/pick-weaverse-data.ts +89 -0
  60. package/src/utils/pixel.ts +71 -0
  61. package/src/utils/studio-request-info.ts +66 -0
  62. package/src/utils/studio-script-src.ts +162 -0
  63. package/src/utils/sync-reused-instance.ts +57 -0
  64. package/src/utils/theme-text-store.ts +1 -0
  65. package/src/utils/translation-store.ts +65 -0
  66. package/src/utils/use-studio.ts +168 -0
  67. package/src/utils/use-theme-settings-store.ts +160 -0
  68. package/src/weaverse-client.ts +1179 -0
  69. package/dist/index.d.mts +0 -1269
  70. package/dist/index.d.ts +0 -1269
@@ -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
+ }