@adobe/spacecat-shared-tokowaka-client 1.4.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js CHANGED
@@ -92,6 +92,35 @@ class TokowakaClient {
92
92
  return error;
93
93
  }
94
94
 
95
+ /**
96
+ * Gets the list of CDN providers from environment configuration
97
+ * Supports both single provider (string) and multiple providers (comma-separated string or array)
98
+ * @returns {Array<string>} Array of CDN provider names
99
+ * @private
100
+ */
101
+ #getCdnProviders() {
102
+ const providerConfig = this.env.TOKOWAKA_CDN_PROVIDER;
103
+
104
+ if (!providerConfig) {
105
+ return [];
106
+ }
107
+
108
+ // If it's already an array, return it
109
+ if (Array.isArray(providerConfig)) {
110
+ return providerConfig.filter(Boolean);
111
+ }
112
+
113
+ // If it's a comma-separated string, split it
114
+ if (typeof providerConfig === 'string') {
115
+ return providerConfig
116
+ .split(',')
117
+ .map((p) => p.trim())
118
+ .filter(Boolean);
119
+ }
120
+
121
+ return [];
122
+ }
123
+
95
124
  /**
96
125
  * Generates Tokowaka site configuration from suggestions for a specific URL
97
126
  * @param {string} url - Full URL for which to generate config
@@ -155,16 +184,15 @@ class TokowakaClient {
155
184
  /**
156
185
  * Fetches domain-level metaconfig from S3
157
186
  * @param {string} url - Full URL (used to extract domain)
158
- * @param {boolean} isPreview - Whether to fetch from preview path (default: false)
159
187
  * @returns {Promise<Object|null>} - Metaconfig object or null if not found
160
188
  */
161
- async fetchMetaconfig(url, isPreview = false) {
189
+ async fetchMetaconfig(url) {
162
190
  if (!hasText(url)) {
163
191
  throw this.#createError('URL is required', HTTP_BAD_REQUEST);
164
192
  }
165
193
 
166
- const s3Path = getTokowakaMetaconfigS3Path(url, this.log, isPreview);
167
- const bucketName = isPreview ? this.previewBucketName : this.deployBucketName;
194
+ const s3Path = getTokowakaMetaconfigS3Path(url, this.log);
195
+ const bucketName = this.deployBucketName;
168
196
 
169
197
  try {
170
198
  const command = new GetObjectCommand({
@@ -195,10 +223,9 @@ class TokowakaClient {
195
223
  * Uploads domain-level metaconfig to S3
196
224
  * @param {string} url - Full URL (used to extract domain)
197
225
  * @param {Object} metaconfig - Metaconfig object (siteId, prerender)
198
- * @param {boolean} isPreview - Whether to upload to preview path (default: false)
199
226
  * @returns {Promise<string>} - S3 key of uploaded metaconfig
200
227
  */
201
- async uploadMetaconfig(url, metaconfig, isPreview = false) {
228
+ async uploadMetaconfig(url, metaconfig) {
202
229
  if (!hasText(url)) {
203
230
  throw this.#createError('URL is required', HTTP_BAD_REQUEST);
204
231
  }
@@ -207,8 +234,8 @@ class TokowakaClient {
207
234
  throw this.#createError('Metaconfig object is required', HTTP_BAD_REQUEST);
208
235
  }
209
236
 
210
- const s3Path = getTokowakaMetaconfigS3Path(url, this.log, isPreview);
211
- const bucketName = isPreview ? this.previewBucketName : this.deployBucketName;
237
+ const s3Path = getTokowakaMetaconfigS3Path(url, this.log);
238
+ const bucketName = this.deployBucketName;
212
239
 
213
240
  try {
214
241
  const command = new PutObjectCommand({
@@ -221,6 +248,10 @@ class TokowakaClient {
221
248
  await this.s3Client.send(command);
222
249
  this.log.info(`Successfully uploaded metaconfig to s3://${bucketName}/${s3Path}`);
223
250
 
251
+ // Invalidate CDN cache for the metaconfig (both CloudFront and Fastly)
252
+ this.log.info('Invalidating CDN cache for uploaded metaconfig');
253
+ await this.invalidateCdnCache({ paths: [`/${s3Path}`] });
254
+
224
255
  return s3Path;
225
256
  } catch (error) {
226
257
  this.log.error(`Failed to upload metaconfig to S3: ${error.message}`, error);
@@ -341,41 +372,86 @@ class TokowakaClient {
341
372
  }
342
373
 
343
374
  /**
344
- * Invalidates CDN cache for the Tokowaka config for a specific URL
345
- * Currently supports CloudFront only
346
- * @param {string} url - Full URL (e.g., 'https://www.example.com/products/item')
347
- * @param {string} provider - CDN provider name (default: 'cloudfront')
348
- * @param {boolean} isPreview - Whether to invalidate preview path (default: false)
349
- * @returns {Promise<Object|null>} - CDN invalidation result or null if skipped
375
+ * CDN cache invalidation method that supports invalidating URL configs
376
+ * or custom S3 paths across provided or default CDN providers
377
+ * @param {Object} options - Invalidation options
378
+ * @param {Array<string>} options.urls - Array of full URLs to invalidate (for URL configs)
379
+ * @param {Array<string>} options.paths - Custom S3 paths to invalidate directly
380
+ * @param {string|Array<string>} options.providers - CDN provider name(s)
381
+ * (default: all supported providers)
382
+ * @param {boolean} options.isPreview - Whether to invalidate preview paths (default: false)
383
+ * @returns {Promise<Array<Object>>} - Array of CDN invalidation results
350
384
  */
351
- async invalidateCdnCache(url, provider, isPreview = false) {
352
- if (!hasText(url) || !hasText(provider)) {
353
- throw this.#createError('URL and provider are required', HTTP_BAD_REQUEST);
385
+ async invalidateCdnCache({
386
+ urls = [],
387
+ paths = [],
388
+ providers = this.#getCdnProviders(),
389
+ isPreview = false,
390
+ }) {
391
+ // Convert single provider to array for uniform handling
392
+ const providerList = Array.isArray(providers) ? providers : [providers].filter(Boolean);
393
+
394
+ if (providerList.length === 0) {
395
+ this.log.warn('No CDN providers specified for cache invalidation');
396
+ return [];
354
397
  }
355
- try {
356
- const pathsToInvalidate = [`/${getTokowakaConfigS3Path(url, this.log, isPreview)}`];
357
- this.log.debug(`Invalidating CDN cache for ${pathsToInvalidate.length} paths via ${provider}`);
358
- const cdnClient = this.cdnClientRegistry.getClient(provider);
359
- if (!cdnClient) {
360
- throw this.#createError(`No CDN client available for provider: ${provider}`, HTTP_NOT_IMPLEMENTED);
361
- }
362
- const result = await cdnClient.invalidateCache(pathsToInvalidate);
363
- this.log.info(`CDN cache invalidation completed: ${JSON.stringify(result)}`);
364
- return result;
365
- } catch (error) {
366
- this.log.error(`Failed to invalidate Tokowaka CDN cache: ${error.message}`, error);
367
- return {
368
- status: 'error',
369
- provider: 'cloudfront',
370
- message: error.message,
371
- };
398
+
399
+ // Build list of paths to invalidate
400
+ const pathsToInvalidate = [...paths];
401
+
402
+ // Add URL config paths
403
+ if (urls.length > 0) {
404
+ const urlPaths = urls.map((url) => `/${getTokowakaConfigS3Path(url, this.log, isPreview)}`);
405
+ pathsToInvalidate.push(...urlPaths);
372
406
  }
407
+
408
+ // Return early if no paths to invalidate
409
+ if (pathsToInvalidate.length === 0) {
410
+ this.log.debug('No paths to invalidate for CDN cache');
411
+ return [];
412
+ }
413
+
414
+ this.log.info(
415
+ `Invalidating CDN cache for ${pathsToInvalidate.length} path(s) `
416
+ + `via providers: ${providerList.join(', ')}`,
417
+ );
418
+
419
+ // Invalidate all providers in parallel
420
+ const invalidationPromises = providerList.map(async (provider) => {
421
+ try {
422
+ const cdnClient = this.cdnClientRegistry.getClient(provider);
423
+ if (!cdnClient) {
424
+ this.log.warn(`No CDN client available for provider: ${provider}`);
425
+ return {
426
+ status: 'error',
427
+ provider,
428
+ message: `No CDN client available for provider: ${provider}`,
429
+ };
430
+ }
431
+
432
+ const result = await cdnClient.invalidateCache(pathsToInvalidate);
433
+ this.log.info(
434
+ `CDN cache invalidation completed for ${provider}: `
435
+ + `${pathsToInvalidate.length} path(s)`,
436
+ );
437
+ return result;
438
+ } catch (error) {
439
+ this.log.warn(`Failed to invalidate ${provider} CDN cache: ${error.message}`, error);
440
+ return {
441
+ status: 'error',
442
+ provider,
443
+ message: error.message,
444
+ };
445
+ }
446
+ });
447
+
448
+ // Wait for all provider invalidations to complete
449
+ const results = await Promise.all(invalidationPromises);
450
+ return results;
373
451
  }
374
452
 
375
453
  /**
376
- * Deploys suggestions to Tokowaka by generating config and uploading to S3
377
- * Now creates one file per URL instead of a single file with all URLs
378
- * Also creates/updates domain-level metadata if needed
454
+ * Deploys suggestions to Tokowaka by generating patch config and uploading to S3
379
455
  * @param {Object} site - Site entity
380
456
  * @param {Object} opportunity - Opportunity entity
381
457
  * @param {Array} suggestions - Array of suggestion entities to deploy
@@ -384,7 +460,6 @@ class TokowakaClient {
384
460
  async deploySuggestions(site, opportunity, suggestions) {
385
461
  const opportunityType = opportunity.getType();
386
462
  const baseURL = getEffectiveBaseURL(site);
387
- const siteId = site.getId();
388
463
  const mapper = this.mapperRegistry.getMapper(opportunityType);
389
464
  if (!mapper) {
390
465
  throw this.#createError(
@@ -416,24 +491,21 @@ class TokowakaClient {
416
491
  // Group suggestions by URL
417
492
  const suggestionsByUrl = groupSuggestionsByUrlPath(eligibleSuggestions, baseURL, this.log);
418
493
 
419
- // Check/create domain-level metaconfig (only need to do this once per deployment)
494
+ // Check if domain-level metaconfig exists
420
495
  const firstUrl = new URL(Object.keys(suggestionsByUrl)[0], baseURL).toString();
421
- let metaconfig = await this.fetchMetaconfig(firstUrl);
496
+ const metaconfig = await this.fetchMetaconfig(firstUrl);
422
497
 
423
498
  if (!metaconfig) {
424
- this.log.info('Creating domain-level metaconfig');
425
- metaconfig = {
426
- siteId,
427
- prerender: mapper.requiresPrerender(),
428
- };
429
- await this.uploadMetaconfig(firstUrl, metaconfig);
430
- } else {
431
- this.log.debug('Domain-level metaconfig already exists');
499
+ throw this.#createError(
500
+ 'No domain-level metaconfig found. '
501
+ + 'A domain-level metaconfig needs to be created first before deploying suggestions.',
502
+ HTTP_BAD_REQUEST,
503
+ );
432
504
  }
433
505
 
434
506
  // Process each URL separately
435
507
  const s3Paths = [];
436
- const cdnInvalidations = [];
508
+ const deployedUrls = []; // Track URLs for batch CDN invalidation
437
509
 
438
510
  for (const [urlPath, urlSuggestions] of Object.entries(suggestionsByUrl)) {
439
511
  const fullUrl = new URL(urlPath, baseURL).toString();
@@ -468,18 +540,14 @@ class TokowakaClient {
468
540
  // eslint-disable-next-line no-await-in-loop
469
541
  const s3Path = await this.uploadConfig(fullUrl, config);
470
542
  s3Paths.push(s3Path);
471
-
472
- // Invalidate CDN cache (non-blocking, failures are logged but don't fail deployment)
473
- // eslint-disable-next-line no-await-in-loop
474
- const cdnInvalidationResult = await this.invalidateCdnCache(
475
- fullUrl,
476
- this.env.TOKOWAKA_CDN_PROVIDER,
477
- );
478
- cdnInvalidations.push(cdnInvalidationResult);
543
+ deployedUrls.push(fullUrl);
479
544
  }
480
545
 
481
546
  this.log.info(`Uploaded Tokowaka configs for ${s3Paths.length} URLs`);
482
547
 
548
+ // Invalidate CDN cache for all deployed URLs at once
549
+ const cdnInvalidations = await this.invalidateCdnCache({ urls: deployedUrls });
550
+
483
551
  return {
484
552
  s3Paths,
485
553
  cdnInvalidations,
@@ -533,7 +601,7 @@ class TokowakaClient {
533
601
 
534
602
  // Process each URL separately
535
603
  const s3Paths = [];
536
- const cdnInvalidations = [];
604
+ const rolledBackUrls = []; // Track URLs for batch CDN invalidation
537
605
  let totalRemovedCount = 0;
538
606
 
539
607
  for (const [urlPath, urlSuggestions] of Object.entries(suggestionsByUrl)) {
@@ -567,14 +635,7 @@ class TokowakaClient {
567
635
  // eslint-disable-next-line no-await-in-loop
568
636
  const s3Path = await this.uploadConfig(fullUrl, updatedConfig);
569
637
  s3Paths.push(s3Path);
570
-
571
- // Invalidate CDN cache
572
- // eslint-disable-next-line no-await-in-loop
573
- const cdnInvalidationResult = await this.invalidateCdnCache(
574
- fullUrl,
575
- this.env.TOKOWAKA_CDN_PROVIDER,
576
- );
577
- cdnInvalidations.push(cdnInvalidationResult);
638
+ rolledBackUrls.push(fullUrl); // Collect URL for batch invalidation
578
639
 
579
640
  totalRemovedCount += 1; // Count as 1 rollback
580
641
  // eslint-disable-next-line no-continue
@@ -610,18 +671,15 @@ class TokowakaClient {
610
671
  // eslint-disable-next-line no-await-in-loop
611
672
  const s3Path = await this.uploadConfig(fullUrl, updatedConfig);
612
673
  s3Paths.push(s3Path);
613
-
614
- // Invalidate CDN cache (non-blocking, failures are logged but don't fail rollback)
615
- // eslint-disable-next-line no-await-in-loop
616
- const cdnInvalidationResult = await this.invalidateCdnCache(
617
- fullUrl,
618
- this.env.TOKOWAKA_CDN_PROVIDER,
619
- );
620
- cdnInvalidations.push(cdnInvalidationResult);
674
+ rolledBackUrls.push(fullUrl); // Collect URL for batch invalidation
621
675
  }
622
676
 
623
677
  this.log.info(`Updated Tokowaka configs for ${s3Paths.length} URLs, removed ${totalRemovedCount} patches total`);
624
678
 
679
+ // Batch invalidate CDN cache for all rolled back URLs at once
680
+ // (much more efficient than individual invalidations)
681
+ const cdnInvalidations = await this.invalidateCdnCache({ urls: rolledBackUrls });
682
+
625
683
  return {
626
684
  s3Paths,
627
685
  cdnInvalidations,
@@ -745,12 +803,11 @@ class TokowakaClient {
745
803
  this.log.info(`Uploading preview Tokowaka config with ${eligibleSuggestions.length} new suggestions`);
746
804
  const s3Path = await this.uploadConfig(previewUrl, config, true);
747
805
 
748
- // Invalidate CDN cache for preview path
749
- const cdnInvalidationResult = await this.invalidateCdnCache(
750
- previewUrl,
751
- this.env.TOKOWAKA_CDN_PROVIDER,
752
- true,
753
- );
806
+ // Invalidate CDN cache for all providers in parallel (preview path)
807
+ const cdnInvalidationResults = await this.invalidateCdnCache({
808
+ urls: [previewUrl],
809
+ isPreview: true,
810
+ });
754
811
 
755
812
  // Fetch HTML content for preview
756
813
  let originalHtml = null;
@@ -789,7 +846,7 @@ class TokowakaClient {
789
846
  return {
790
847
  s3Path,
791
848
  config,
792
- cdnInvalidation: cdnInvalidationResult,
849
+ cdnInvalidations: cdnInvalidationResults,
793
850
  succeededSuggestions: eligibleSuggestions,
794
851
  failedSuggestions: ineligibleSuggestions,
795
852
  html: {
@@ -57,7 +57,7 @@ export default class GenericMapper extends BaseOpportunityMapper {
57
57
  ...this.createBasePatch(suggestion, opportunityId),
58
58
  op: transformRules.action,
59
59
  selector: transformRules.selector,
60
- value: data.format === 'hast' ? JSON.parse(data.patchValue) : data.patchValue,
60
+ value: (data.format === 'hast' || data.format === 'json') ? JSON.parse(data.patchValue) : data.patchValue,
61
61
  valueFormat: data.format || 'text',
62
62
  target: TARGET_USER_AGENTS_CATEGORIES.AI_BOTS,
63
63
  };
@@ -66,6 +66,10 @@ export default class GenericMapper extends BaseOpportunityMapper {
66
66
  patch.tag = data.tag;
67
67
  }
68
68
 
69
+ if (data.attrs) {
70
+ patch.attrs = JSON.parse(data.attrs);
71
+ }
72
+
69
73
  patches.push(patch);
70
74
  });
71
75