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

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,
@@ -642,11 +700,11 @@ class TokowakaClient {
642
700
  */
643
701
  async previewSuggestions(site, opportunity, suggestions, options = {}) {
644
702
  // Get site's forwarded host for preview
645
- const { forwardedHost, apiKey } = site.getConfig()?.getTokowakaConfig() || {};
703
+ const { forwardedHost } = site.getConfig()?.getTokowakaConfig() || {};
646
704
 
647
- if (!hasText(forwardedHost) || !hasText(apiKey)) {
705
+ if (!hasText(forwardedHost)) {
648
706
  throw this.#createError(
649
- 'Site does not have a Tokowaka API key or forwarded host configured. '
707
+ 'Site does not have a Tokowaka forwarded host configured. '
650
708
  + 'Please onboard the site to Tokowaka first.',
651
709
  HTTP_BAD_REQUEST,
652
710
  );
@@ -662,6 +720,15 @@ class TokowakaClient {
662
720
  );
663
721
  }
664
722
 
723
+ // TOKOWAKA_PREVIEW_API_KEY is mandatory for preview
724
+ const tokowakaPreviewApiKey = this.env.TOKOWAKA_PREVIEW_API_KEY;
725
+ if (!hasText(tokowakaPreviewApiKey)) {
726
+ throw this.#createError(
727
+ 'TOKOWAKA_PREVIEW_API_KEY is required for preview functionality',
728
+ HTTP_INTERNAL_SERVER_ERROR,
729
+ );
730
+ }
731
+
665
732
  // TOKOWAKA_EDGE_URL is mandatory for preview
666
733
  const tokowakaEdgeUrl = this.env.TOKOWAKA_EDGE_URL;
667
734
  if (!hasText(tokowakaEdgeUrl)) {
@@ -745,12 +812,11 @@ class TokowakaClient {
745
812
  this.log.info(`Uploading preview Tokowaka config with ${eligibleSuggestions.length} new suggestions`);
746
813
  const s3Path = await this.uploadConfig(previewUrl, config, true);
747
814
 
748
- // Invalidate CDN cache for preview path
749
- const cdnInvalidationResult = await this.invalidateCdnCache(
750
- previewUrl,
751
- this.env.TOKOWAKA_CDN_PROVIDER,
752
- true,
753
- );
815
+ // Invalidate CDN cache for all providers in parallel (preview path)
816
+ const cdnInvalidationResults = await this.invalidateCdnCache({
817
+ urls: [previewUrl],
818
+ isPreview: true,
819
+ });
754
820
 
755
821
  // Fetch HTML content for preview
756
822
  let originalHtml = null;
@@ -760,7 +826,7 @@ class TokowakaClient {
760
826
  // Fetch original HTML (without preview)
761
827
  originalHtml = await fetchHtmlWithWarmup(
762
828
  previewUrl,
763
- apiKey,
829
+ tokowakaPreviewApiKey,
764
830
  forwardedHost,
765
831
  tokowakaEdgeUrl,
766
832
  this.log,
@@ -770,7 +836,7 @@ class TokowakaClient {
770
836
  // Then fetch optimized HTML (with preview)
771
837
  optimizedHtml = await fetchHtmlWithWarmup(
772
838
  previewUrl,
773
- apiKey,
839
+ tokowakaPreviewApiKey,
774
840
  forwardedHost,
775
841
  tokowakaEdgeUrl,
776
842
  this.log,
@@ -789,7 +855,7 @@ class TokowakaClient {
789
855
  return {
790
856
  s3Path,
791
857
  config,
792
- cdnInvalidation: cdnInvalidationResult,
858
+ cdnInvalidations: cdnInvalidationResults,
793
859
  succeededSuggestions: eligibleSuggestions,
794
860
  failedSuggestions: ineligibleSuggestions,
795
861
  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
 
@@ -89,7 +89,7 @@ async function fetchWithRetry(url, options, maxRetries, retryDelayMs, log, fetch
89
89
  * Fetches HTML content from Tokowaka edge with warmup call and retry logic
90
90
  * Makes an initial warmup call, waits, then makes the actual call with retries
91
91
  * @param {string} url - Full URL to fetch
92
- * @param {string} apiKey - Tokowaka API key
92
+ * @param {string} previewApiKey - Tokowaka preview API key (internal)
93
93
  * @param {string} forwardedHost - Host to forward in x-forwarded-host header
94
94
  * @param {string} tokowakaEdgeUrl - Tokowaka edge URL
95
95
  * @param {boolean} isOptimized - Whether to fetch optimized HTML (with preview param)
@@ -103,7 +103,7 @@ async function fetchWithRetry(url, options, maxRetries, retryDelayMs, log, fetch
103
103
  */
104
104
  export async function fetchHtmlWithWarmup(
105
105
  url,
106
- apiKey,
106
+ previewApiKey,
107
107
  forwardedHost,
108
108
  tokowakaEdgeUrl,
109
109
  log,
@@ -115,8 +115,8 @@ export async function fetchHtmlWithWarmup(
115
115
  throw new Error('URL is required for fetching HTML');
116
116
  }
117
117
 
118
- if (!hasText(apiKey)) {
119
- throw new Error('Tokowaka API key is required for fetching HTML');
118
+ if (!hasText(previewApiKey)) {
119
+ throw new Error('Tokowaka preview API key is required for fetching HTML');
120
120
  }
121
121
 
122
122
  if (!hasText(forwardedHost)) {
@@ -143,7 +143,7 @@ export async function fetchHtmlWithWarmup(
143
143
 
144
144
  const headers = {
145
145
  'x-forwarded-host': forwardedHost,
146
- 'x-tokowaka-api-key': apiKey,
146
+ 'x-tokowaka-preview-api-key': previewApiKey,
147
147
  'x-tokowaka-url': urlPath,
148
148
  };
149
149