@adobe/spacecat-shared-tokowaka-client 1.4.4 → 1.4.5

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/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [@adobe/spacecat-shared-tokowaka-client-v1.4.5](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.4.4...@adobe/spacecat-shared-tokowaka-client-v1.4.5) (2026-01-07)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * fetch api key from s3 and fix invalidation sequence ([#1251](https://github.com/adobe/spacecat-shared/issues/1251)) ([fe66bd6](https://github.com/adobe/spacecat-shared/commit/fe66bd6732dc200ab4df27babc1e0831f6187271))
7
+
1
8
  # [@adobe/spacecat-shared-tokowaka-client-v1.4.4](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.4.3...@adobe/spacecat-shared-tokowaka-client-v1.4.4) (2026-01-05)
2
9
 
3
10
 
package/README.md CHANGED
@@ -146,21 +146,17 @@ The client invalidates CDN cache after uploading configurations. Failures are lo
146
146
 
147
147
  ## Site Configuration
148
148
 
149
- Sites must have the following configuration in their `tokowakaConfig`:
149
+ Sites must have the `tokowakaConfig` field in their site-config to confirm that tokowaka is enabled for that particular site.
150
150
 
151
151
  ```javascript
152
152
  {
153
+ ...
153
154
  "tokowakaConfig": {
154
- "apiKey": "legacy-key-kept-for-backward-compatibility", // Optional, kept for backward compatibility
155
- "forwardedHost": "www.example.com" // Required for preview functionality
155
+ "enabled": true
156
156
  }
157
157
  }
158
158
  ```
159
159
 
160
- **Note:**
161
- - `apiKey` is optional and **not used** for S3 paths or HTTP headers (kept in schema for potential future use)
162
- - `forwardedHost` is **required** for preview functionality to fetch HTML from Tokowaka edge
163
-
164
160
  ## Supported Opportunity Types
165
161
 
166
162
  ### Headings
@@ -215,6 +211,7 @@ Domain-level metaconfig (created once per domain, shared by all URLs):
215
211
  ```json
216
212
  {
217
213
  "siteId": "abc-123",
214
+ "apiKeys": ["tokowaka-api-key-1"],
218
215
  "prerender": true
219
216
  }
220
217
  ```
@@ -308,7 +305,7 @@ TOKOWAKA_CDN_CONFIG='{"cloudfront":{"distributionId":"...","region":"us-east-1"}
308
305
 
309
306
  **Behavior:**
310
307
  - All URLs are batched into a single invalidation request per provider
311
- - All CDN providers are invalidated in parallel using `Promise.all()`
308
+ - All CDN providers are invalidated sequentially
312
309
  - Failures in one CDN don't block others
313
310
  - Each CDN returns its own result in the `cdnInvalidations` array
314
311
  - Results include status, provider name, and provider-specific details
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-tokowaka-client",
3
- "version": "1.4.4",
3
+ "version": "1.4.5",
4
4
  "description": "Tokowaka Client for SpaceCat - Edge optimization config management",
5
5
  "type": "module",
6
6
  "engines": {
package/src/index.d.ts CHANGED
@@ -34,6 +34,7 @@ export const TARGET_USER_AGENTS_CATEGORIES: {
34
34
 
35
35
  export interface TokowakaMetaconfig {
36
36
  siteId: string;
37
+ apiKeys?: string[];
37
38
  prerender?: {
38
39
  allowList?: string[];
39
40
  } | boolean;
package/src/index.js CHANGED
@@ -18,7 +18,7 @@ import { mergePatches } from './utils/patch-utils.js';
18
18
  import { getTokowakaConfigS3Path, getTokowakaMetaconfigS3Path } from './utils/s3-utils.js';
19
19
  import { groupSuggestionsByUrlPath, filterEligibleSuggestions } from './utils/suggestion-utils.js';
20
20
  import { getEffectiveBaseURL } from './utils/site-utils.js';
21
- import { fetchHtmlWithWarmup } from './utils/custom-html-utils.js';
21
+ import { fetchHtmlWithWarmup, calculateForwardedHost } from './utils/custom-html-utils.js';
22
22
 
23
23
  const HTTP_BAD_REQUEST = 400;
24
24
  const HTTP_INTERNAL_SERVER_ERROR = 500;
@@ -222,7 +222,7 @@ class TokowakaClient {
222
222
  /**
223
223
  * Uploads domain-level metaconfig to S3
224
224
  * @param {string} url - Full URL (used to extract domain)
225
- * @param {Object} metaconfig - Metaconfig object (siteId, prerender)
225
+ * @param {Object} metaconfig - Metaconfig object (siteId, apiKeys, prerender)
226
226
  * @returns {Promise<string>} - S3 key of uploaded metaconfig
227
227
  */
228
228
  async uploadMetaconfig(url, metaconfig) {
@@ -373,7 +373,7 @@ class TokowakaClient {
373
373
 
374
374
  /**
375
375
  * CDN cache invalidation method that supports invalidating URL configs
376
- * or custom S3 paths across provided or default CDN providers
376
+ * or custom S3 paths across provided or default CDN providers.
377
377
  * @param {Object} options - Invalidation options
378
378
  * @param {Array<string>} options.urls - Array of full URLs to invalidate (for URL configs)
379
379
  * @param {Array<string>} options.paths - Custom S3 paths to invalidate directly
@@ -416,37 +416,37 @@ class TokowakaClient {
416
416
  + `via providers: ${providerList.join(', ')}`,
417
417
  );
418
418
 
419
- // Invalidate all providers in parallel
420
- const invalidationPromises = providerList.map(async (provider) => {
419
+ const results = [];
420
+ for (const provider of providerList) {
421
421
  try {
422
422
  const cdnClient = this.cdnClientRegistry.getClient(provider);
423
423
  if (!cdnClient) {
424
424
  this.log.warn(`No CDN client available for provider: ${provider}`);
425
- return {
425
+ results.push({
426
426
  status: 'error',
427
427
  provider,
428
428
  message: `No CDN client available for provider: ${provider}`,
429
- };
429
+ });
430
+ // eslint-disable-next-line no-continue
431
+ continue;
430
432
  }
431
433
 
434
+ // eslint-disable-next-line no-await-in-loop
432
435
  const result = await cdnClient.invalidateCache(pathsToInvalidate);
433
436
  this.log.info(
434
437
  `CDN cache invalidation completed for ${provider}: `
435
438
  + `${pathsToInvalidate.length} path(s)`,
436
439
  );
437
- return result;
440
+ results.push(result);
438
441
  } catch (error) {
439
442
  this.log.warn(`Failed to invalidate ${provider} CDN cache: ${error.message}`, error);
440
- return {
443
+ results.push({
441
444
  status: 'error',
442
445
  provider,
443
446
  message: error.message,
444
- };
447
+ });
445
448
  }
446
- });
447
-
448
- // Wait for all provider invalidations to complete
449
- const results = await Promise.all(invalidationPromises);
449
+ }
450
450
  return results;
451
451
  }
452
452
 
@@ -699,17 +699,6 @@ class TokowakaClient {
699
699
  * @returns {Promise<Object>} - Preview result with config and succeeded/failed suggestions
700
700
  */
701
701
  async previewSuggestions(site, opportunity, suggestions, options = {}) {
702
- // Get site's forwarded host for preview
703
- const { forwardedHost, apiKey } = site.getConfig()?.getTokowakaConfig() || {};
704
-
705
- if (!hasText(forwardedHost) || !hasText(apiKey)) {
706
- throw this.#createError(
707
- 'Site does not have a Tokowaka API key or forwarded host configured. '
708
- + 'Please onboard the site to Tokowaka first.',
709
- HTTP_BAD_REQUEST,
710
- );
711
- }
712
-
713
702
  const opportunityType = opportunity.getType();
714
703
  const mapper = this.mapperRegistry.getMapper(opportunityType);
715
704
  if (!mapper) {
@@ -755,6 +744,29 @@ class TokowakaClient {
755
744
  throw this.#createError('Preview URL not found in suggestion data', HTTP_BAD_REQUEST);
756
745
  }
757
746
 
747
+ // Fetch metaconfig to get API key
748
+ const metaconfig = await this.fetchMetaconfig(previewUrl);
749
+
750
+ if (!metaconfig) {
751
+ throw this.#createError(
752
+ 'No domain-level metaconfig found. '
753
+ + 'A domain-level metaconfig needs to be created first before previewing suggestions.',
754
+ HTTP_INTERNAL_SERVER_ERROR,
755
+ );
756
+ }
757
+
758
+ const { apiKeys } = metaconfig;
759
+ if (!Array.isArray(apiKeys) || apiKeys.length === 0 || !hasText(apiKeys[0])) {
760
+ throw this.#createError(
761
+ 'Metaconfig does not have valid API keys configured. '
762
+ + 'Please ensure the metaconfig has at least one API key.',
763
+ HTTP_INTERNAL_SERVER_ERROR,
764
+ );
765
+ }
766
+
767
+ const apiKey = apiKeys[0];
768
+ const forwardedHost = calculateForwardedHost(previewUrl, this.log);
769
+
758
770
  // Fetch existing deployed configuration for this URL from production S3
759
771
  this.log.debug(`Fetching existing deployed Tokowaka config for URL: ${previewUrl}`);
760
772
  const existingConfig = await this.fetchConfig(previewUrl, false);
@@ -193,3 +193,29 @@ export async function fetchHtmlWithWarmup(
193
193
  throw new Error(errorMsg);
194
194
  }
195
195
  }
196
+
197
+ export function calculateForwardedHost(url, logger = console) {
198
+ try {
199
+ const urlObj = new URL(url);
200
+ const { hostname } = urlObj;
201
+
202
+ // If hostname already starts with www., keep it as is
203
+ if (hostname.startsWith('www.')) {
204
+ logger.debug(`Forwarded host: ${hostname}`);
205
+ return hostname;
206
+ }
207
+
208
+ // Count dots to determine if it's a bare domain or has a subdomain
209
+ const dotCount = (hostname.match(/\./g) || []).length;
210
+
211
+ // If only 1 dot (bare domain like example.com), prepend www.
212
+ // If 2+ dots (subdomain like subdomain.example.com), keep as is
213
+ const forwardedHost = dotCount === 1 ? `www.${hostname}` : hostname;
214
+
215
+ logger.debug(`Forwarded host: ${forwardedHost}`);
216
+ return forwardedHost;
217
+ } catch (error) {
218
+ logger.error(`Error calculating forwarded host from URL ${url}: ${error.message}`);
219
+ throw new Error(`Error calculating forwarded host from URL ${url}: ${error.message}`);
220
+ }
221
+ }
@@ -1698,6 +1698,12 @@ describe('TokowakaClient', () => {
1698
1698
  // Stub fetchConfig to return null by default (no existing config)
1699
1699
  sinon.stub(client, 'fetchConfig').resolves(null);
1700
1700
 
1701
+ // Stub fetchMetaconfig to return metaconfig with apiKeys array
1702
+ sinon.stub(client, 'fetchMetaconfig').resolves({
1703
+ siteId: 'site-123',
1704
+ apiKeys: ['test-api-key-1', 'test-api-key-2'],
1705
+ });
1706
+
1701
1707
  // Add TOKOWAKA_EDGE_URL to env
1702
1708
  client.env.TOKOWAKA_EDGE_URL = 'https://edge-dev.tokowaka.now';
1703
1709
  });
@@ -1804,31 +1810,60 @@ describe('TokowakaClient', () => {
1804
1810
  }
1805
1811
  });
1806
1812
 
1807
- it('should throw error if site does not have forwardedHost', async () => {
1808
- mockSite.getConfig = () => ({
1809
- getTokowakaConfig: () => ({}),
1813
+ it('should throw error if metaconfig does not exist', async () => {
1814
+ client.fetchMetaconfig.resolves(null);
1815
+
1816
+ try {
1817
+ await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
1818
+ expect.fail('Should have thrown error');
1819
+ } catch (error) {
1820
+ expect(error.message).to.include('No domain-level metaconfig found');
1821
+ expect(error.status).to.equal(500);
1822
+ }
1823
+ });
1824
+
1825
+ it('should throw error if metaconfig does not have apiKeys', async () => {
1826
+ client.fetchMetaconfig.resolves({
1827
+ siteId: 'site-123',
1828
+ // apiKeys missing
1810
1829
  });
1811
1830
 
1812
1831
  try {
1813
1832
  await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
1814
1833
  expect.fail('Should have thrown error');
1815
1834
  } catch (error) {
1816
- expect(error.message).to.include('Site does not have a Tokowaka API key or forwarded host configured');
1817
- expect(error.status).to.equal(400);
1835
+ expect(error.message).to.include('Metaconfig does not have valid API keys configured');
1836
+ expect(error.status).to.equal(500);
1818
1837
  }
1819
1838
  });
1820
1839
 
1821
- it('should throw error if getTokowakaConfig returns null', async () => {
1822
- mockSite.getConfig = () => ({
1823
- getTokowakaConfig: () => null,
1840
+ it('should throw error if metaconfig has empty apiKeys array', async () => {
1841
+ client.fetchMetaconfig.resolves({
1842
+ siteId: 'site-123',
1843
+ apiKeys: [],
1824
1844
  });
1825
1845
 
1826
1846
  try {
1827
1847
  await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
1828
1848
  expect.fail('Should have thrown error');
1829
1849
  } catch (error) {
1830
- expect(error.message).to.include('Site does not have a Tokowaka API key or forwarded host configured');
1831
- expect(error.status).to.equal(400);
1850
+ expect(error.message).to.include('Metaconfig does not have valid API keys configured');
1851
+ expect(error.status).to.equal(500);
1852
+ }
1853
+ });
1854
+
1855
+ it('should throw error if metaconfig apiKeys first value is empty', async () => {
1856
+ client.fetchMetaconfig.resolves({
1857
+ siteId: 'site-123',
1858
+ apiKeys: ['', 'test-api-key-2'],
1859
+ });
1860
+
1861
+ try {
1862
+ await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
1863
+ expect.fail('Should have thrown error');
1864
+ } catch (error) {
1865
+ expect(error.message).to.include('Metaconfig does not have valid API keys configured');
1866
+ expect(error.status).to.equal(500);
1832
1867
  }
1833
1868
  });
1834
1869
 
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { expect } from 'chai';
16
16
  import sinon from 'sinon';
17
- import { fetchHtmlWithWarmup } from '../../src/utils/custom-html-utils.js';
17
+ import { fetchHtmlWithWarmup, calculateForwardedHost } from '../../src/utils/custom-html-utils.js';
18
18
 
19
19
  describe('HTML Utils', () => {
20
20
  describe('fetchHtmlWithWarmup', () => {
@@ -429,4 +429,104 @@ describe('HTML Utils', () => {
429
429
  expect(fetchStub.callCount).to.equal(2); // warmup + 1 actual
430
430
  });
431
431
  });
432
+
433
+ describe('calculateForwardedHost', () => {
434
+ let log;
435
+
436
+ beforeEach(() => {
437
+ log = {
438
+ debug: sinon.stub(),
439
+ warn: sinon.stub(),
440
+ error: sinon.stub(),
441
+ info: sinon.stub(),
442
+ };
443
+ });
444
+
445
+ afterEach(() => {
446
+ sinon.restore();
447
+ });
448
+
449
+ it('should add www prefix to bare domain', async () => {
450
+ const result = calculateForwardedHost('https://example.com/page', log);
451
+ expect(result).to.equal('www.example.com');
452
+ });
453
+
454
+ it('should keep www prefix when already present', async () => {
455
+ const result = calculateForwardedHost('https://www.example.com/page', log);
456
+ expect(result).to.equal('www.example.com');
457
+ });
458
+
459
+ it('should keep subdomain without adding www', async () => {
460
+ const result = calculateForwardedHost('https://subdomain.example.com/page', log);
461
+ expect(result).to.equal('subdomain.example.com');
462
+ });
463
+
464
+ it('should keep www.subdomain as is', async () => {
465
+ const result = calculateForwardedHost('https://www.subdomain.example.com/page', log);
466
+ expect(result).to.equal('www.subdomain.example.com');
467
+ });
468
+
469
+ it('should add www to bare domain with port', async () => {
470
+ const result = calculateForwardedHost('https://example.com:8080/page', log);
471
+ expect(result).to.equal('www.example.com');
472
+ });
473
+
474
+ it('should keep www with port', async () => {
475
+ const result = calculateForwardedHost('https://www.example.com:8080/page', log);
476
+ expect(result).to.equal('www.example.com');
477
+ });
478
+
479
+ it('should handle URL with query parameters', async () => {
480
+ const result = calculateForwardedHost('https://example.com/page?param=value', log);
481
+ expect(result).to.equal('www.example.com');
482
+ });
483
+
484
+ it('should handle URL with hash fragment', async () => {
485
+ const result = calculateForwardedHost('https://example.com/page#section', log);
486
+ expect(result).to.equal('www.example.com');
487
+ });
488
+
489
+ it('should handle http protocol', async () => {
490
+ const result = calculateForwardedHost('http://example.com/page', log);
491
+ expect(result).to.equal('www.example.com');
492
+ });
493
+
494
+ it('should handle deep subdomains', async () => {
495
+ const result = calculateForwardedHost('https://level1.level2.example.com/page', log);
496
+ expect(result).to.equal('level1.level2.example.com');
497
+ });
498
+
499
+ it('should throw error for invalid URL', async () => {
500
+ try {
501
+ calculateForwardedHost('not-a-valid-url', log);
502
+ expect.fail('Should have thrown error');
503
+ } catch (error) {
504
+ expect(error.message).to.include('Error calculating forwarded host from URL');
505
+ expect(error.message).to.include('not-a-valid-url');
506
+ expect(log.error).to.have.been.calledOnce;
507
+ }
508
+ });
509
+
510
+ it('should throw error for empty URL', async () => {
511
+ try {
512
+ calculateForwardedHost('', log);
513
+ expect.fail('Should have thrown error');
514
+ } catch (error) {
515
+ expect(error.message).to.include('Error calculating forwarded host from URL');
516
+ expect(log.error).to.have.been.calledOnce;
517
+ }
518
+ });
519
+
520
+ it('should use console as default logger when not provided', async () => {
521
+ const result = calculateForwardedHost('https://example.com/page');
522
+ expect(result).to.equal('www.example.com');
523
+ });
524
+
525
+ it('should handle hostname with no dots (edge case)', async () => {
526
+ // This is an edge case for localhost or single-word hostnames
527
+ const result = calculateForwardedHost('http://localhost/page', log);
528
+ // No dots means dotCount = 0, so it won't match dotCount === 1, keeps as is
529
+ expect(result).to.equal('localhost');
530
+ });
531
+ });
432
532
  });