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

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,17 @@
1
+ # [@adobe/spacecat-shared-tokowaka-client-v1.4.7](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.4.6...@adobe/spacecat-shared-tokowaka-client-v1.4.7) (2026-01-14)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * update tokowaka s3 config with patches ([#1263](https://github.com/adobe/spacecat-shared/issues/1263)) ([59605e9](https://github.com/adobe/spacecat-shared/commit/59605e9b75b7289029bd14e1832e594ea565296d))
7
+
8
+ # [@adobe/spacecat-shared-tokowaka-client-v1.4.6](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.4.5...@adobe/spacecat-shared-tokowaka-client-v1.4.6) (2026-01-13)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * add api to create/update s3 edge config ([#1260](https://github.com/adobe/spacecat-shared/issues/1260)) ([b5cfff2](https://github.com/adobe/spacecat-shared/commit/b5cfff29cdb6fa29e1b440f86eb86cf8dd471e43))
14
+
1
15
  # [@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
16
 
3
17
 
package/README.md CHANGED
@@ -64,10 +64,12 @@ Creates a client instance from a context object.
64
64
 
65
65
  #### `deploySuggestions(site, opportunity, suggestions)`
66
66
 
67
- Generates configuration and uploads to S3 **per URL**. **Automatically fetches existing configuration for each URL and merges** new suggestions with it. Invalidates CDN cache after upload.
67
+ Generates configuration and uploads to S3 **per URL**. **Automatically fetches existing configuration for each URL and merges** new suggestions with it. Invalidates CDN cache after upload. **Updates the metaconfig's `patches` field** to track deployed endpoints.
68
68
 
69
69
  **Architecture Change:** Creates one S3 file per URL instead of a single file with all URLs. This prevents files from growing too large over time.
70
70
 
71
+ **Metaconfig Tracking:** After successful deployment, the method updates the domain-level metaconfig's `patches` object with the normalized paths of all deployed endpoints (e.g., `{ "/products/item": true }`). This provides a centralized registry of all deployed endpoints for the domain.
72
+
71
73
  **Returns:** `Promise<DeploymentResult>` with:
72
74
  - `s3Paths` - Array of S3 keys where configs were uploaded (one per URL)
73
75
  - `cdnInvalidations` - Array of CDN invalidation results (one per URL per provider)
@@ -212,7 +214,17 @@ Domain-level metaconfig (created once per domain, shared by all URLs):
212
214
  {
213
215
  "siteId": "abc-123",
214
216
  "apiKeys": ["tokowaka-api-key-1"],
215
- "prerender": true
217
+ "tokowakaEnabled": true,
218
+ "enhancements": true,
219
+ "prerender": {
220
+ "allowList": [],
221
+ "denyList": ["/*"]
222
+ },
223
+ "patches": {
224
+ "/products/item": true,
225
+ "/about": true,
226
+ "/contact": true
227
+ }
216
228
  }
217
229
  ```
218
230
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-tokowaka-client",
3
- "version": "1.4.5",
3
+ "version": "1.4.7",
4
4
  "description": "Tokowaka Client for SpaceCat - Edge optimization config management",
5
5
  "type": "module",
6
6
  "engines": {
@@ -38,7 +38,8 @@
38
38
  "@aws-sdk/client-cloudfront": "3.940.0",
39
39
  "@aws-sdk/client-s3": "3.940.0",
40
40
  "mdast-util-from-markdown": "2.0.2",
41
- "mdast-util-to-hast": "12.3.0"
41
+ "mdast-util-to-hast": "12.3.0",
42
+ "uuid": "11.0.5"
42
43
  },
43
44
  "devDependencies": {
44
45
  "aws-sdk-client-mock": "4.1.0",
package/src/index.js CHANGED
@@ -10,12 +10,19 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
+ import crypto from 'crypto';
13
14
  import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
14
15
  import { hasText, isNonEmptyObject } from '@adobe/spacecat-shared-utils';
16
+ import { v4 as uuidv4 } from 'uuid';
15
17
  import MapperRegistry from './mappers/mapper-registry.js';
16
18
  import CdnClientRegistry from './cdn/cdn-client-registry.js';
17
19
  import { mergePatches } from './utils/patch-utils.js';
18
- import { getTokowakaConfigS3Path, getTokowakaMetaconfigS3Path } from './utils/s3-utils.js';
20
+ import {
21
+ getTokowakaConfigS3Path,
22
+ getTokowakaMetaconfigS3Path,
23
+ getHostName,
24
+ normalizePath,
25
+ } from './utils/s3-utils.js';
19
26
  import { groupSuggestionsByUrlPath, filterEligibleSuggestions } from './utils/suggestion-utils.js';
20
27
  import { getEffectiveBaseURL } from './utils/site-utils.js';
21
28
  import { fetchHtmlWithWarmup, calculateForwardedHost } from './utils/custom-html-utils.js';
@@ -92,6 +99,44 @@ class TokowakaClient {
92
99
  return error;
93
100
  }
94
101
 
102
+ /**
103
+ * Updates the metaconfig with deployed endpoint paths
104
+ * @param {Object} metaconfig - Existing metaconfig object
105
+ * @param {Array<string>} deployedUrls - Array of successfully deployed URLs
106
+ * @param {string} baseUrl - Base URL for uploading metaconfig
107
+ * @returns {Promise<void>}
108
+ * @private
109
+ */
110
+ async #updateMetaconfigWithDeployedPaths(metaconfig, deployedUrls, baseUrl) {
111
+ if (!Array.isArray(deployedUrls) || deployedUrls.length === 0) {
112
+ return;
113
+ }
114
+
115
+ try {
116
+ // Initialize patches field if it doesn't exist
117
+ const updatedMetaconfig = {
118
+ ...metaconfig,
119
+ patches: { ...(metaconfig.patches || {}) },
120
+ };
121
+
122
+ // Extract normalized paths from deployed URLs and add to patches object
123
+ deployedUrls.forEach((url) => {
124
+ const urlObj = new URL(url);
125
+ const normalizedPath = normalizePath(urlObj.pathname);
126
+ updatedMetaconfig.patches[normalizedPath] = true;
127
+ });
128
+
129
+ await this.uploadMetaconfig(baseUrl, updatedMetaconfig);
130
+ this.log.info(`Updated metaconfig with ${deployedUrls.length} deployed endpoint(s)`);
131
+ } catch (error) {
132
+ this.log.error(`Failed to update metaconfig with deployed paths: ${error.message}`, error);
133
+ throw this.#createError(
134
+ `Failed to update metaconfig with deployed paths: ${error.message}`,
135
+ HTTP_INTERNAL_SERVER_ERROR,
136
+ );
137
+ }
138
+ }
139
+
95
140
  /**
96
141
  * Gets the list of CDN providers from environment configuration
97
142
  * Supports both single provider (string) and multiple providers (comma-separated string or array)
@@ -219,6 +264,56 @@ class TokowakaClient {
219
264
  }
220
265
  }
221
266
 
267
+ /**
268
+ * Generates an API key for Tokowaka based on domain
269
+ * @param {string} domain - Domain name (e.g., 'example.com')
270
+ * @returns {string} - Base64 URL-encoded API key
271
+ * @private
272
+ */
273
+ /* eslint-disable class-methods-use-this */
274
+ #generateApiKey(normalizedHostName) {
275
+ const uuid = uuidv4();
276
+ return crypto
277
+ .createHash('sha256')
278
+ .update(`${uuid}${normalizedHostName}`)
279
+ .digest('base64url');
280
+ }
281
+
282
+ /**
283
+ * Creates and uploads domain-level metaconfig to S3
284
+ * Generates a new API key and creates the metaconfig structure
285
+ * @param {string} url - Full URL (used to extract domain)
286
+ * @param {string} siteId - Site ID
287
+ * @param {Object} options - Optional configuration
288
+ * @param {boolean} options.tokowakaEnabled - Whether to enable Tokowaka (default: true)
289
+ * @returns {Promise<Object>} - Object with s3Path and metaconfig
290
+ */
291
+ async createMetaconfig(url, siteId, options = {}) {
292
+ if (!hasText(url)) {
293
+ throw this.#createError('URL is required', HTTP_BAD_REQUEST);
294
+ }
295
+
296
+ if (!hasText(siteId)) {
297
+ throw this.#createError('Site ID is required', HTTP_BAD_REQUEST);
298
+ }
299
+
300
+ const normalizedHostName = getHostName(url, this.log);
301
+ const apiKey = this.#generateApiKey(normalizedHostName);
302
+
303
+ const metaconfig = {
304
+ siteId,
305
+ apiKeys: [apiKey],
306
+ tokowakaEnabled: options.tokowakaEnabled ?? true,
307
+ enhancements: false,
308
+ };
309
+
310
+ const s3Path = await this.uploadMetaconfig(url, metaconfig);
311
+
312
+ this.log.info(`Created new Tokowaka metaconfig for ${normalizedHostName} at ${s3Path}`);
313
+
314
+ return metaconfig;
315
+ }
316
+
222
317
  /**
223
318
  * Uploads domain-level metaconfig to S3
224
319
  * @param {string} url - Full URL (used to extract domain)
@@ -545,6 +640,9 @@ class TokowakaClient {
545
640
 
546
641
  this.log.info(`Uploaded Tokowaka configs for ${s3Paths.length} URLs`);
547
642
 
643
+ // Update metaconfig with deployed paths
644
+ await this.#updateMetaconfigWithDeployedPaths(metaconfig, deployedUrls, baseURL);
645
+
548
646
  // Invalidate CDN cache for all deployed URLs at once
549
647
  const cdnInvalidations = await this.invalidateCdnCache({ urls: deployedUrls });
550
648
 
@@ -35,10 +35,17 @@ export function normalizePath(pathname) {
35
35
  * @returns {string} - Normalized hostname
36
36
  * @throws {Error} - If hostname extraction fails
37
37
  */
38
- export function getHostName(url, logger) {
38
+ export function getHostName(url, logger = console) {
39
39
  try {
40
- const finalHostname = url.hostname.replace(/^www\./, '');
41
- return finalHostname;
40
+ let urlObj;
41
+ if (url instanceof URL) {
42
+ urlObj = url;
43
+ } else if (typeof url === 'string') {
44
+ urlObj = new URL(url);
45
+ } else {
46
+ throw new TypeError('Input must be a URL or a string');
47
+ }
48
+ return urlObj.hostname.replace(/^www\./, '');
42
49
  } catch (error) {
43
50
  logger.error(`Error extracting host name: ${error.message}`);
44
51
  throw new Error(`Error extracting host name: ${url.toString()}`);
@@ -456,6 +456,92 @@ describe('TokowakaClient', () => {
456
456
  });
457
457
  });
458
458
 
459
+ describe('createMetaconfig', () => {
460
+ it('should create metaconfig with generated API key and default options', async () => {
461
+ const siteId = 'site-123';
462
+ const url = 'https://www.example.com/page1';
463
+
464
+ const result = await client.createMetaconfig(url, siteId);
465
+
466
+ expect(result).to.have.property('siteId', siteId);
467
+ expect(result).to.have.property('apiKeys');
468
+ expect(result.apiKeys).to.be.an('array').with.lengthOf(1);
469
+ expect(result.apiKeys[0]).to.be.a('string');
470
+ expect(result).to.have.property('tokowakaEnabled', true);
471
+ expect(result).to.have.property('enhancements', false);
472
+
473
+ // Verify uploadMetaconfig was called with correct metaconfig
474
+ expect(s3Client.send).to.have.been.calledOnce;
475
+ const command = s3Client.send.firstCall.args[0];
476
+ expect(command.input.Bucket).to.equal('test-bucket');
477
+ expect(command.input.Key).to.equal('opportunities/example.com/config');
478
+ });
479
+
480
+ it('should create metaconfig with tokowakaEnabled set to false', async () => {
481
+ const siteId = 'site-123';
482
+ const url = 'https://example.com';
483
+
484
+ const result = await client.createMetaconfig(url, siteId, { tokowakaEnabled: false });
485
+
486
+ expect(result).to.have.property('tokowakaEnabled', false);
487
+ expect(result).to.have.property('enhancements', false);
488
+ expect(result.apiKeys).to.have.lengthOf(1);
489
+ });
490
+
491
+ it('should create metaconfig with tokowakaEnabled set to true explicitly', async () => {
492
+ const siteId = 'site-123';
493
+ const url = 'https://example.com';
494
+
495
+ const result = await client.createMetaconfig(url, siteId, { tokowakaEnabled: true });
496
+
497
+ expect(result).to.have.property('tokowakaEnabled', true);
498
+ expect(result).to.have.property('enhancements', false);
499
+ });
500
+
501
+ it('should throw error if URL is missing', async () => {
502
+ try {
503
+ await client.createMetaconfig('', 'site-123');
504
+ expect.fail('Should have thrown error');
505
+ } catch (error) {
506
+ expect(error.message).to.include('URL is required');
507
+ expect(error.status).to.equal(400);
508
+ }
509
+ });
510
+
511
+ it('should throw error if siteId is missing', async () => {
512
+ try {
513
+ await client.createMetaconfig('https://example.com', '');
514
+ expect.fail('Should have thrown error');
515
+ } catch (error) {
516
+ expect(error.message).to.include('Site ID is required');
517
+ expect(error.status).to.equal(400);
518
+ }
519
+ });
520
+
521
+ it('should handle S3 upload failure', async () => {
522
+ const s3Error = new Error('S3 network error');
523
+ s3Client.send.rejects(s3Error);
524
+
525
+ try {
526
+ await client.createMetaconfig('https://example.com', 'site-123');
527
+ expect.fail('Should have thrown error');
528
+ } catch (error) {
529
+ expect(error.message).to.include('S3 upload failed');
530
+ expect(error.status).to.equal(500);
531
+ }
532
+ });
533
+
534
+ it('should strip www. from domain in metaconfig path', async () => {
535
+ const siteId = 'site-123';
536
+ const url = 'https://www.example.com/some/path';
537
+
538
+ await client.createMetaconfig(url, siteId);
539
+
540
+ const command = s3Client.send.firstCall.args[0];
541
+ expect(command.input.Key).to.equal('opportunities/example.com/config');
542
+ });
543
+ });
544
+
459
545
  describe('uploadConfig', () => {
460
546
  it('should upload config to S3', async () => {
461
547
  const config = {
@@ -944,6 +1030,161 @@ describe('TokowakaClient', () => {
944
1030
  expect(result.failedSuggestions[0].suggestion.getId()).to.equal('sugg-2');
945
1031
  });
946
1032
 
1033
+ it('should update metaconfig patches field with deployed endpoints', async () => {
1034
+ mockSuggestions = [
1035
+ {
1036
+ getId: () => 'sugg-1',
1037
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
1038
+ getData: () => ({
1039
+ url: 'https://example.com/page1',
1040
+ recommendedAction: 'Page 1 Heading',
1041
+ checkType: 'heading-empty',
1042
+ transformRules: {
1043
+ action: 'replace',
1044
+ selector: 'h1',
1045
+ },
1046
+ }),
1047
+ },
1048
+ {
1049
+ getId: () => 'sugg-2',
1050
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
1051
+ getData: () => ({
1052
+ url: 'https://example.com/page2',
1053
+ recommendedAction: 'Page 2 Heading',
1054
+ checkType: 'heading-empty',
1055
+ transformRules: {
1056
+ action: 'replace',
1057
+ selector: 'h1',
1058
+ },
1059
+ }),
1060
+ },
1061
+ ];
1062
+
1063
+ const result = await client.deploySuggestions(
1064
+ mockSite,
1065
+ mockOpportunity,
1066
+ mockSuggestions,
1067
+ );
1068
+
1069
+ expect(result.succeededSuggestions).to.have.length(2);
1070
+ // Verify uploadMetaconfig was called to update the metaconfig
1071
+ expect(client.uploadMetaconfig).to.have.been.called;
1072
+ // Check that the last call included the patches field
1073
+ const { lastCall } = client.uploadMetaconfig;
1074
+ expect(lastCall.args[1]).to.have.property('patches');
1075
+ expect(lastCall.args[1].patches).to.deep.equal({
1076
+ '/page1': true,
1077
+ '/page2': true,
1078
+ });
1079
+ });
1080
+
1081
+ it('should add to existing patches in metaconfig when deploying new endpoints', async () => {
1082
+ // Set up metaconfig with existing patches
1083
+ // Reset the stub to provide consistent behavior
1084
+ client.fetchMetaconfig.reset();
1085
+ client.fetchMetaconfig.resolves({
1086
+ siteId: 'site-123',
1087
+ prerender: true,
1088
+ patches: {
1089
+ '/existing-page': true,
1090
+ },
1091
+ });
1092
+
1093
+ mockSuggestions = [
1094
+ {
1095
+ getId: () => 'sugg-1',
1096
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
1097
+ getData: () => ({
1098
+ url: 'https://example.com/new-page',
1099
+ recommendedAction: 'New Heading',
1100
+ checkType: 'heading-empty',
1101
+ transformRules: {
1102
+ action: 'replace',
1103
+ selector: 'h1',
1104
+ },
1105
+ }),
1106
+ },
1107
+ ];
1108
+
1109
+ await client.deploySuggestions(
1110
+ mockSite,
1111
+ mockOpportunity,
1112
+ mockSuggestions,
1113
+ );
1114
+
1115
+ // Verify the updated metaconfig includes both existing and new patches
1116
+ const { lastCall } = client.uploadMetaconfig;
1117
+ expect(lastCall.args[1].patches).to.deep.equal({
1118
+ '/existing-page': true,
1119
+ '/new-page': true,
1120
+ });
1121
+ });
1122
+
1123
+ it('should throw error when metaconfig update fails', async () => {
1124
+ // Make uploadMetaconfig fail during the update
1125
+ client.uploadMetaconfig.rejects(new Error('S3 upload error'));
1126
+
1127
+ try {
1128
+ await client.deploySuggestions(
1129
+ mockSite,
1130
+ mockOpportunity,
1131
+ mockSuggestions,
1132
+ );
1133
+ expect.fail('Should have thrown error');
1134
+ } catch (error) {
1135
+ expect(error.message).to.include('Failed to update metaconfig with deployed paths');
1136
+ expect(error.status).to.equal(500);
1137
+ }
1138
+ });
1139
+
1140
+ it('should return early when no eligible suggestions to deploy', async () => {
1141
+ // All suggestions are ineligible
1142
+ mockSuggestions = [
1143
+ {
1144
+ getId: () => 'sugg-1',
1145
+ getData: () => ({
1146
+ url: 'https://example.com/page1',
1147
+ recommendedAction: 'New Heading',
1148
+ checkType: 'heading-missing', // Not eligible
1149
+ }),
1150
+ },
1151
+ ];
1152
+
1153
+ const result = await client.deploySuggestions(
1154
+ mockSite,
1155
+ mockOpportunity,
1156
+ mockSuggestions,
1157
+ );
1158
+
1159
+ // No suggestions deployed - returns early before metaconfig check
1160
+ expect(result.succeededSuggestions).to.have.length(0);
1161
+ expect(result.failedSuggestions).to.have.length(1);
1162
+ // fetchMetaconfig should not be called at all (returns before that point)
1163
+ expect(client.fetchMetaconfig).to.not.have.been.called;
1164
+ // uploadMetaconfig should not be called at all
1165
+ expect(client.uploadMetaconfig).to.not.have.been.called;
1166
+ });
1167
+
1168
+ it('should not update metaconfig when all URLs fail to generate configs', async () => {
1169
+ // Stub generateConfig to return null (no config generated)
1170
+ sinon.stub(client, 'generateConfig').returns(null);
1171
+
1172
+ const result = await client.deploySuggestions(
1173
+ mockSite,
1174
+ mockOpportunity,
1175
+ mockSuggestions,
1176
+ );
1177
+
1178
+ // Suggestions are marked as succeeded (eligible) but no configs uploaded
1179
+ expect(result.succeededSuggestions).to.have.length(2);
1180
+ expect(result.s3Paths).to.have.length(0); // No configs uploaded
1181
+ // fetchMetaconfig called once for initial check, but not for update
1182
+ // since no URLs were actually deployed (deployedUrls is empty)
1183
+ expect(client.fetchMetaconfig).to.have.been.calledOnce;
1184
+ // uploadMetaconfig should not be called at all
1185
+ expect(client.uploadMetaconfig).to.not.have.been.called;
1186
+ });
1187
+
947
1188
  it('should skip URL when generateConfig returns no patches', async () => {
948
1189
  // Stub mapper to return empty patches for the first call, normal for subsequent calls
949
1190
  const mapper = client.mapperRegistry.getMapper('headings');