@ecomiq/storefront 2.1.3 → 2.1.4

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.
@@ -1,378 +0,0 @@
1
- import { z } from 'zod'
2
- import { StorefrontDefinitionSchema } from '@ecomiq/storefront-definition'
3
-
4
- export const CloudRuntimeStoreSlugSchema = z
5
- .string()
6
- .trim()
7
- .min(2)
8
- .max(100)
9
- .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
10
-
11
- export const ActiveStorefrontRuntimeSchema = z
12
- .object({
13
- schemaVersion: z.literal(1),
14
- store: z
15
- .object({
16
- slug: CloudRuntimeStoreSlugSchema,
17
- })
18
- .strict(),
19
- environment: z
20
- .object({
21
- name: z.literal('production'),
22
- status: z.literal('ready'),
23
- })
24
- .strict(),
25
- publication: z
26
- .object({
27
- sequence: z.number().int().nonnegative(),
28
- releaseSequence: z.number().int().nonnegative(),
29
- publishedAt: z.string().datetime({ offset: true }),
30
- runtimeReleaseId: z.string().optional(),
31
- artifactDigest: z.string().optional(),
32
- })
33
- .strict(),
34
- definition: StorefrontDefinitionSchema,
35
- })
36
- .strict()
37
-
38
- export type ActiveStorefrontRuntime = z.infer<
39
- typeof ActiveStorefrontRuntimeSchema
40
- >
41
-
42
- export const EnvironmentPointerSchema = z
43
- .object({
44
- schemaVersion: z.literal(1),
45
- storeSlug: CloudRuntimeStoreSlugSchema,
46
- projectId: z.string().min(1),
47
- environmentId: z.string().min(1),
48
- generation: z.number().int().nonnegative(),
49
- releaseSequence: z.number().int().nonnegative(),
50
- deploymentSequence: z.number().int().nonnegative(),
51
- artifactDigest: z.string().min(1),
52
- artifactKey: z.string().min(1),
53
- manifestDigest: z.string().min(1),
54
- manifestKey: z.string().min(1),
55
- runtimeReleaseId: z.string().min(1),
56
- publishedAt: z.string().datetime({ offset: true }),
57
- cachePolicy: z
58
- .object({
59
- pointerMaxAgeSeconds: z.number().int().nonnegative(),
60
- staleIfErrorSeconds: z.number().int().nonnegative(),
61
- })
62
- .strict(),
63
- })
64
- .strict()
65
-
66
- export type EnvironmentPointer = z.infer<typeof EnvironmentPointerSchema>
67
-
68
- export type CloudRuntimeFetch = (
69
- input: RequestInfo | URL,
70
- init?: RequestInit,
71
- ) => Promise<Response>
72
-
73
- export interface CloudRuntimeClientOptions {
74
- baseUrl: string
75
- artifactBaseUrl?: string
76
- timeoutMs?: number
77
- cacheTtlMs?: number
78
- fetch?: CloudRuntimeFetch
79
- now?: () => number
80
- }
81
-
82
- interface RuntimeCacheEntry {
83
- value: ActiveStorefrontRuntime | null
84
- etag?: string
85
- expiresAt: number
86
- }
87
-
88
- export class CloudRuntimeResponseError extends Error {
89
- readonly status: number
90
-
91
- constructor(status: number) {
92
- super(`Cloud runtime respondió HTTP ${status}`)
93
- this.name = 'CloudRuntimeResponseError'
94
- this.status = status
95
- }
96
- }
97
-
98
- const DEFAULT_TIMEOUT_MS = 3_000
99
- const DEFAULT_CACHE_TTL_MS = 15_000
100
- const MAX_CACHE_ENTRIES = 100
101
-
102
- interface LastKnownGood {
103
- runtime: ActiveStorefrontRuntime
104
- generation: number
105
- }
106
-
107
- export class CloudRuntimeClient {
108
- readonly baseUrl: string
109
- readonly artifactBaseUrl: string | null
110
- readonly timeoutMs: number
111
- readonly cacheTtlMs: number
112
-
113
- private readonly fetchRuntime: CloudRuntimeFetch
114
- private readonly now: () => number
115
- private readonly cache = new Map<string, RuntimeCacheEntry>()
116
- private readonly lastKnownGood = new Map<string, LastKnownGood>()
117
- private readonly artifactCache = new Map<string, ActiveStorefrontRuntime>()
118
- private readonly inFlight = new Map<
119
- string,
120
- Promise<ActiveStorefrontRuntime | null>
121
- >()
122
-
123
- constructor(options: CloudRuntimeClientOptions) {
124
- this.baseUrl = new URL(options.baseUrl).toString().replace(/\/$/, '')
125
- this.artifactBaseUrl = options.artifactBaseUrl
126
- ? new URL(options.artifactBaseUrl).toString().replace(/\/$/, '')
127
- : null
128
- this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
129
- this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS
130
- this.fetchRuntime = options.fetch ?? globalThis.fetch
131
- this.now = options.now ?? Date.now
132
- }
133
-
134
- async resolveActiveStorefront(
135
- storeSlugInput: string,
136
- ): Promise<ActiveStorefrontRuntime | null> {
137
- const storeSlug = CloudRuntimeStoreSlugSchema.parse(storeSlugInput)
138
- const cached = this.cache.get(storeSlug)
139
- if (cached && cached.expiresAt > this.now()) {
140
- return cached.value
141
- }
142
-
143
- const activeRequest = this.inFlight.get(storeSlug)
144
- if (activeRequest) return activeRequest
145
-
146
- const request = (
147
- this.artifactBaseUrl
148
- ? this.requestFromArtifactStore(storeSlug)
149
- : this.requestActiveStorefront(storeSlug, cached)
150
- ).finally(() => {
151
- this.inFlight.delete(storeSlug)
152
- })
153
- this.inFlight.set(storeSlug, request)
154
- return request
155
- }
156
-
157
- clearCache(): void {
158
- this.cache.clear()
159
- this.lastKnownGood.clear()
160
- this.artifactCache.clear()
161
- this.inFlight.clear()
162
- }
163
-
164
- private async requestFromArtifactStore(
165
- storeSlug: string,
166
- ): Promise<ActiveStorefrontRuntime | null> {
167
- const survivor = this.lastKnownGood.get(storeSlug)
168
- let pointer: EnvironmentPointer | null
169
- try {
170
- pointer = await this.fetchPointer(storeSlug)
171
- } catch {
172
- if (survivor) return survivor.runtime
173
- throw new CloudRuntimeResponseError(503)
174
- }
175
-
176
- if (pointer === null) {
177
- if (survivor) return survivor.runtime
178
- this.setCache(storeSlug, {
179
- value: null,
180
- expiresAt: this.now() + this.cacheTtlMs,
181
- })
182
- return null
183
- }
184
-
185
- if (survivor && pointer.generation < survivor.generation) {
186
- this.setCache(storeSlug, {
187
- value: survivor.runtime,
188
- expiresAt: this.now() + this.cacheTtlMs,
189
- })
190
- return survivor.runtime
191
- }
192
-
193
- let runtime: ActiveStorefrontRuntime
194
- try {
195
- runtime = await this.fetchArtifactRuntime(pointer)
196
- } catch {
197
- if (survivor) return survivor.runtime
198
- throw new CloudRuntimeResponseError(503)
199
- }
200
-
201
- if (runtime.store.slug !== storeSlug) {
202
- throw new Error(
203
- `El pointer de ${storeSlug} apunta a un artifact de ${runtime.store.slug}`,
204
- )
205
- }
206
-
207
- const ttlMs = pointer.cachePolicy.pointerMaxAgeSeconds * 1_000
208
- this.setCache(storeSlug, {
209
- value: runtime,
210
- expiresAt: this.now() + (ttlMs > 0 ? ttlMs : this.cacheTtlMs),
211
- })
212
- this.lastKnownGood.set(storeSlug, {
213
- runtime,
214
- generation: pointer.generation,
215
- })
216
- return runtime
217
- }
218
-
219
- private async fetchPointer(
220
- storeSlug: string,
221
- ): Promise<EnvironmentPointer | null> {
222
- const response = await this.fetchWithTimeout(
223
- `${this.artifactBaseUrl}/stores/${encodeURIComponent(storeSlug)}/pointer.json`,
224
- )
225
- if (response.status === 404) return null
226
- if (!response.ok) throw new CloudRuntimeResponseError(response.status)
227
- return EnvironmentPointerSchema.parse(await response.json())
228
- }
229
-
230
- private async fetchArtifactRuntime(
231
- pointer: EnvironmentPointer,
232
- ): Promise<ActiveStorefrontRuntime> {
233
- const cached = this.artifactCache.get(pointer.artifactKey)
234
- const base =
235
- cached ?? (await this.requestArtifactBody(pointer.artifactKey))
236
- if (!cached) {
237
- if (this.artifactCache.size >= MAX_CACHE_ENTRIES) {
238
- const oldestKey = this.artifactCache.keys().next().value
239
- if (oldestKey !== undefined) this.artifactCache.delete(oldestKey)
240
- }
241
- this.artifactCache.set(pointer.artifactKey, base)
242
- }
243
- return {
244
- ...base,
245
- publication: {
246
- sequence: pointer.deploymentSequence,
247
- releaseSequence: pointer.releaseSequence,
248
- publishedAt: pointer.publishedAt,
249
- runtimeReleaseId: pointer.runtimeReleaseId,
250
- artifactDigest: pointer.artifactDigest,
251
- },
252
- }
253
- }
254
-
255
- private async requestArtifactBody(
256
- artifactKey: string,
257
- ): Promise<ActiveStorefrontRuntime> {
258
- const response = await this.fetchWithTimeout(
259
- `${this.artifactBaseUrl}/${artifactKey}`,
260
- )
261
- if (!response.ok) throw new CloudRuntimeResponseError(response.status)
262
- return ActiveStorefrontRuntimeSchema.parse(await response.json())
263
- }
264
-
265
- private async fetchWithTimeout(url: string): Promise<Response> {
266
- const controller = new AbortController()
267
- const timeout = setTimeout(() => controller.abort(), this.timeoutMs)
268
- try {
269
- return await this.fetchRuntime(url, {
270
- method: 'GET',
271
- headers: { accept: 'application/json' },
272
- signal: controller.signal,
273
- })
274
- } finally {
275
- clearTimeout(timeout)
276
- }
277
- }
278
-
279
- private setCache(storeSlug: string, entry: RuntimeCacheEntry): void {
280
- if (!this.cache.has(storeSlug) && this.cache.size >= MAX_CACHE_ENTRIES) {
281
- const oldestKey = this.cache.keys().next().value
282
- if (oldestKey !== undefined) this.cache.delete(oldestKey)
283
- }
284
- this.cache.set(storeSlug, entry)
285
- }
286
-
287
- private async requestActiveStorefront(
288
- storeSlug: string,
289
- cached?: RuntimeCacheEntry,
290
- ): Promise<ActiveStorefrontRuntime | null> {
291
- const controller = new AbortController()
292
- const timeout = setTimeout(() => controller.abort(), this.timeoutMs)
293
- const headers = new Headers({ accept: 'application/json' })
294
- if (cached?.etag) headers.set('if-none-match', cached.etag)
295
-
296
- try {
297
- const response = await this.fetchRuntime(
298
- new URL(
299
- `/v1/runtime/storefronts/${encodeURIComponent(storeSlug)}`,
300
- this.baseUrl,
301
- ),
302
- {
303
- method: 'GET',
304
- headers,
305
- signal: controller.signal,
306
- },
307
- )
308
-
309
- if (response.status === 304) {
310
- if (!cached) throw new CloudRuntimeResponseError(304)
311
- this.setCache(storeSlug, {
312
- ...cached,
313
- expiresAt: this.now() + this.cacheTtlMs,
314
- })
315
- return cached.value
316
- }
317
-
318
- if (response.status === 404) {
319
- this.setCache(storeSlug, {
320
- value: null,
321
- etag: response.headers.get('etag') ?? undefined,
322
- expiresAt: this.now() + this.cacheTtlMs,
323
- })
324
- return null
325
- }
326
-
327
- if (!response.ok) {
328
- throw new CloudRuntimeResponseError(response.status)
329
- }
330
-
331
- const runtime = ActiveStorefrontRuntimeSchema.parse(
332
- await response.json(),
333
- )
334
- if (runtime.store.slug !== storeSlug) {
335
- throw new Error(
336
- `Cloud runtime devolvió el store slug ${runtime.store.slug} para ${storeSlug}`,
337
- )
338
- }
339
-
340
- this.setCache(storeSlug, {
341
- value: runtime,
342
- etag: response.headers.get('etag') ?? undefined,
343
- expiresAt: this.now() + this.cacheTtlMs,
344
- })
345
- return runtime
346
- } finally {
347
- clearTimeout(timeout)
348
- }
349
- }
350
- }
351
-
352
- const sharedClients = new Map<string, CloudRuntimeClient>()
353
-
354
- export function getCloudRuntimeClient(
355
- options: Omit<CloudRuntimeClientOptions, 'fetch' | 'now'>,
356
- ): CloudRuntimeClient {
357
- const key = [
358
- new URL(options.baseUrl).toString(),
359
- options.artifactBaseUrl ? new URL(options.artifactBaseUrl).toString() : '',
360
- options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
361
- options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS,
362
- ].join('|')
363
- const current = sharedClients.get(key)
364
- if (current) return current
365
-
366
- const client = new CloudRuntimeClient(options)
367
- if (sharedClients.size >= 8) {
368
- const oldestKey = sharedClients.keys().next().value
369
- if (oldestKey !== undefined) sharedClients.delete(oldestKey)
370
- }
371
- sharedClients.set(key, client)
372
- return client
373
- }
374
-
375
- export function resetCloudRuntimeClients(): void {
376
- for (const client of sharedClients.values()) client.clearCache()
377
- sharedClients.clear()
378
- }
@@ -1 +0,0 @@
1
- export * from '@ecomiq/storefront-definition'
@@ -1,188 +0,0 @@
1
- import type {
2
- Collection,
3
- Collections,
4
- ProductsParamsInput,
5
- ProductsResponse,
6
- ProductSummary,
7
- } from '@ecomiq/core'
8
- import {
9
- type CollectionGridStorefrontSection,
10
- type ProductGridStorefrontSection,
11
- type StorefrontDefinition,
12
- } from '@ecomiq/storefront-definition'
13
- import { storefrontPageFor } from './renderer'
14
- import type { DefinitionData } from './renderer'
15
-
16
- const MAX_UNIQUE_PRODUCT_SOURCES = 12
17
- const MANUAL_PRODUCT_PAGE_SIZE = 100
18
- const MAX_MANUAL_PRODUCT_SCAN_PAGES = 5
19
-
20
- export interface StorefrontRuntimeCatalogClient {
21
- getProducts(params?: ProductsParamsInput): Promise<ProductsResponse>
22
- getCollections(): Promise<Collections>
23
- }
24
-
25
- export interface ResolvedStorefrontDefinitionData
26
- extends DefinitionData {
27
- productsBySectionId: Record<string, ProductSummary[]>
28
- collectionsBySectionId: Record<string, Collection[]>
29
- }
30
-
31
- export function emptyRendererData(): ResolvedStorefrontDefinitionData {
32
- return {
33
- productsBySectionId: {},
34
- collectionsBySectionId: {},
35
- }
36
- }
37
-
38
- function productSourceKey(
39
- section: ProductGridStorefrontSection,
40
- ): string | null {
41
- if (section.source.kind === 'manual') return null
42
- if (section.source.kind === 'collection') {
43
- return `collection:${section.source.slug}`
44
- }
45
- return 'all'
46
- }
47
-
48
- async function loadProductSource(
49
- section: ProductGridStorefrontSection,
50
- client: StorefrontRuntimeCatalogClient,
51
- ): Promise<ProductSummary[]> {
52
- const params: ProductsParamsInput = {
53
- page: 1,
54
- pageSize: section.limit,
55
- sort: 'newest',
56
- }
57
- if (section.source.kind === 'collection') {
58
- params.collection = section.source.slug
59
- }
60
- const response = await client.getProducts(params)
61
- return response.products.slice(0, section.limit)
62
- }
63
-
64
- async function scanManualProducts(
65
- productIds: ReadonlySet<string>,
66
- client: StorefrontRuntimeCatalogClient,
67
- ): Promise<Map<string, ProductSummary>> {
68
- const found = new Map<string, ProductSummary>()
69
-
70
- for (let page = 1; page <= MAX_MANUAL_PRODUCT_SCAN_PAGES; page += 1) {
71
- const response = await client.getProducts({
72
- page,
73
- pageSize: MANUAL_PRODUCT_PAGE_SIZE,
74
- sort: 'newest',
75
- })
76
- for (const product of response.products) {
77
- if (productIds.has(product.id)) found.set(product.id, product)
78
- }
79
-
80
- if (
81
- found.size === productIds.size ||
82
- page >= response.totalPages ||
83
- response.products.length === 0
84
- ) {
85
- break
86
- }
87
- }
88
-
89
- return found
90
- }
91
-
92
- export async function resolveStorefrontDefinitionData(
93
- definition: StorefrontDefinition,
94
- client: StorefrontRuntimeCatalogClient,
95
- pageId?: string,
96
- ): Promise<ResolvedStorefrontDefinitionData> {
97
- const data = emptyRendererData()
98
- const page = storefrontPageFor(definition, pageId)
99
- const sections = page?.sections ?? []
100
- const productSections = sections.filter(
101
- (section): section is ProductGridStorefrontSection =>
102
- section.enabled && section.type === 'product-grid',
103
- )
104
- const collectionSections = sections.filter(
105
- (section): section is CollectionGridStorefrontSection =>
106
- section.enabled && section.type === 'collection-grid',
107
- )
108
-
109
- const sourceSections = new Map<string, ProductGridStorefrontSection>()
110
- for (const section of productSections) {
111
- const sourceKey = productSourceKey(section)
112
- if (!sourceKey) continue
113
- const current = sourceSections.get(sourceKey)
114
- if (current) {
115
- if (section.limit > current.limit) sourceSections.set(sourceKey, section)
116
- continue
117
- }
118
- if (sourceSections.size >= MAX_UNIQUE_PRODUCT_SOURCES) continue
119
- sourceSections.set(sourceKey, section)
120
- }
121
-
122
- const sourceRequests = new Map<string, Promise<ProductSummary[]>>()
123
- for (const [sourceKey, section] of sourceSections) {
124
- sourceRequests.set(
125
- sourceKey,
126
- loadProductSource(section, client).catch(() => []),
127
- )
128
- }
129
-
130
- const manualIds = new Set(
131
- productSections.flatMap((section) =>
132
- section.source.kind === 'manual' ? section.source.productIds : [],
133
- ),
134
- )
135
- const manualProducts =
136
- manualIds.size > 0
137
- ? scanManualProducts(manualIds, client).catch(
138
- () => new Map<string, ProductSummary>(),
139
- )
140
- : Promise.resolve(new Map<string, ProductSummary>())
141
-
142
- await Promise.all(
143
- productSections.map(async (section) => {
144
- if (section.source.kind === 'manual') {
145
- const productsById = await manualProducts
146
- data.productsBySectionId[section.id] = section.source.productIds
147
- .map((productId) => productsById.get(productId))
148
- .filter(
149
- (product): product is ProductSummary => product !== undefined,
150
- )
151
- .slice(0, section.limit)
152
- return
153
- }
154
-
155
- const sourceKey = productSourceKey(section)
156
- const products = sourceKey
157
- ? await (sourceRequests.get(sourceKey) ?? Promise.resolve([]))
158
- : []
159
- data.productsBySectionId[section.id] = products.slice(0, section.limit)
160
- }),
161
- )
162
-
163
- if (collectionSections.length > 0) {
164
- const collections = await client.getCollections().catch(() => [])
165
- const collectionsBySlug = new Map(
166
- collections.map((collection) => [collection.slug, collection]),
167
- )
168
-
169
- for (const section of collectionSections) {
170
- if (section.source.kind === 'manual') {
171
- data.collectionsBySectionId[section.id] = section.source.slugs
172
- .map((slug) => collectionsBySlug.get(slug))
173
- .filter(
174
- (collection): collection is Collection =>
175
- collection !== undefined,
176
- )
177
- .slice(0, section.limit)
178
- continue
179
- }
180
- data.collectionsBySectionId[section.id] = collections.slice(
181
- 0,
182
- section.limit,
183
- )
184
- }
185
- }
186
-
187
- return data
188
- }
@@ -1,2 +0,0 @@
1
- export * from './contract'
2
- export * from './renderer'