@adobe/spacecat-shared-tokowaka-client 1.1.0 → 1.2.0
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 +7 -0
- package/README.md +144 -19
- package/package.json +1 -1
- package/src/index.d.ts +81 -15
- package/src/index.js +314 -260
- package/src/mappers/base-mapper.js +1 -1
- package/src/mappers/faq-mapper.js +18 -23
- package/src/utils/custom-html-utils.js +1 -0
- package/src/utils/patch-utils.js +12 -25
- package/src/utils/s3-utils.js +100 -7
- package/test/index.test.js +808 -990
- package/test/mappers/base-mapper.test.js +72 -88
- package/test/mappers/faq-mapper.test.js +61 -97
- package/test/utils/html-utils.test.js +10 -12
- package/test/utils/patch-utils.test.js +204 -235
- package/test/utils/s3-utils.test.js +140 -0
- package/test/utils/site-utils.test.js +80 -0
- package/test/utils/suggestion-utils.test.js +187 -0
package/src/index.js
CHANGED
|
@@ -15,7 +15,7 @@ import { hasText, isNonEmptyObject } from '@adobe/spacecat-shared-utils';
|
|
|
15
15
|
import MapperRegistry from './mappers/mapper-registry.js';
|
|
16
16
|
import CdnClientRegistry from './cdn/cdn-client-registry.js';
|
|
17
17
|
import { mergePatches } from './utils/patch-utils.js';
|
|
18
|
-
import { getTokowakaConfigS3Path } from './utils/s3-utils.js';
|
|
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
21
|
import { fetchHtmlWithWarmup } from './utils/custom-html-utils.js';
|
|
@@ -93,16 +93,14 @@ class TokowakaClient {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
|
-
* Generates Tokowaka site configuration from suggestions
|
|
97
|
-
* @param {
|
|
96
|
+
* Generates Tokowaka site configuration from suggestions for a specific URL
|
|
97
|
+
* @param {string} url - Full URL for which to generate config
|
|
98
98
|
* @param {Object} opportunity - Opportunity entity
|
|
99
99
|
* @param {Array} suggestionsToDeploy - Array of suggestion entities to deploy
|
|
100
|
-
* @returns {Object} - Tokowaka configuration object
|
|
100
|
+
* @returns {Object} - Tokowaka configuration object for the URL
|
|
101
101
|
*/
|
|
102
|
-
generateConfig(
|
|
102
|
+
generateConfig(url, opportunity, suggestionsToDeploy) {
|
|
103
103
|
const opportunityType = opportunity.getType();
|
|
104
|
-
const siteId = site.getId();
|
|
105
|
-
const baseURL = getEffectiveBaseURL(site);
|
|
106
104
|
|
|
107
105
|
const mapper = this.mapperRegistry.getMapper(opportunityType);
|
|
108
106
|
if (!mapper) {
|
|
@@ -113,33 +111,27 @@ class TokowakaClient {
|
|
|
113
111
|
);
|
|
114
112
|
}
|
|
115
113
|
|
|
116
|
-
//
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
// Generate patches for each URL using the mapper
|
|
120
|
-
const tokowakaOptimizations = {};
|
|
114
|
+
// Extract URL path from the full URL
|
|
115
|
+
const urlObj = new URL(url);
|
|
116
|
+
const urlPath = urlObj.pathname;
|
|
121
117
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
118
|
+
// Generate patches for the URL using the mapper
|
|
119
|
+
const patches = mapper.suggestionsToPatches(
|
|
120
|
+
urlPath,
|
|
121
|
+
suggestionsToDeploy,
|
|
122
|
+
opportunity.getId(),
|
|
123
|
+
);
|
|
128
124
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
patches,
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
});
|
|
125
|
+
if (patches.length === 0) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
136
128
|
|
|
137
129
|
return {
|
|
138
|
-
|
|
139
|
-
baseURL,
|
|
130
|
+
url,
|
|
140
131
|
version: '1.0',
|
|
141
|
-
|
|
142
|
-
|
|
132
|
+
forceFail: false,
|
|
133
|
+
prerender: mapper.requiresPrerender(),
|
|
134
|
+
patches,
|
|
143
135
|
};
|
|
144
136
|
}
|
|
145
137
|
|
|
@@ -160,20 +152,98 @@ class TokowakaClient {
|
|
|
160
152
|
}
|
|
161
153
|
|
|
162
154
|
/**
|
|
163
|
-
* Fetches
|
|
164
|
-
* @param {string}
|
|
155
|
+
* Fetches domain-level metaconfig from S3
|
|
156
|
+
* @param {string} url - Full URL (used to extract domain)
|
|
157
|
+
* @param {boolean} isPreview - Whether to fetch from preview path (default: false)
|
|
158
|
+
* @returns {Promise<Object|null>} - Metaconfig object or null if not found
|
|
159
|
+
*/
|
|
160
|
+
async fetchMetaconfig(url, isPreview = false) {
|
|
161
|
+
if (!hasText(url)) {
|
|
162
|
+
throw this.#createError('URL is required', HTTP_BAD_REQUEST);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const s3Path = getTokowakaMetaconfigS3Path(url, this.log, isPreview);
|
|
166
|
+
const bucketName = isPreview ? this.previewBucketName : this.deployBucketName;
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const command = new GetObjectCommand({
|
|
170
|
+
Bucket: bucketName,
|
|
171
|
+
Key: s3Path,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const response = await this.s3Client.send(command);
|
|
175
|
+
const bodyContents = await response.Body.transformToString();
|
|
176
|
+
const metaconfig = JSON.parse(bodyContents);
|
|
177
|
+
|
|
178
|
+
this.log.debug(`Successfully fetched metaconfig from s3://${bucketName}/${s3Path}`);
|
|
179
|
+
return metaconfig;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
// If metaconfig doesn't exist (NoSuchKey), return null
|
|
182
|
+
if (error.name === 'NoSuchKey' || error.Code === 'NoSuchKey') {
|
|
183
|
+
this.log.debug(`No metaconfig found at s3://${bucketName}/${s3Path}`);
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// For other errors, log and throw
|
|
188
|
+
this.log.error(`Failed to fetch metaconfig from S3: ${error.message}`, error);
|
|
189
|
+
throw this.#createError(`S3 fetch failed: ${error.message}`, HTTP_INTERNAL_SERVER_ERROR);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Uploads domain-level metaconfig to S3
|
|
195
|
+
* @param {string} url - Full URL (used to extract domain)
|
|
196
|
+
* @param {Object} metaconfig - Metaconfig object (siteId, prerender)
|
|
197
|
+
* @param {boolean} isPreview - Whether to upload to preview path (default: false)
|
|
198
|
+
* @returns {Promise<string>} - S3 key of uploaded metaconfig
|
|
199
|
+
*/
|
|
200
|
+
async uploadMetaconfig(url, metaconfig, isPreview = false) {
|
|
201
|
+
if (!hasText(url)) {
|
|
202
|
+
throw this.#createError('URL is required', HTTP_BAD_REQUEST);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!isNonEmptyObject(metaconfig)) {
|
|
206
|
+
throw this.#createError('Metaconfig object is required', HTTP_BAD_REQUEST);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const s3Path = getTokowakaMetaconfigS3Path(url, this.log, isPreview);
|
|
210
|
+
const bucketName = isPreview ? this.previewBucketName : this.deployBucketName;
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const command = new PutObjectCommand({
|
|
214
|
+
Bucket: bucketName,
|
|
215
|
+
Key: s3Path,
|
|
216
|
+
Body: JSON.stringify(metaconfig, null, 2),
|
|
217
|
+
ContentType: 'application/json',
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await this.s3Client.send(command);
|
|
221
|
+
this.log.info(`Successfully uploaded metaconfig to s3://${bucketName}/${s3Path}`);
|
|
222
|
+
|
|
223
|
+
return s3Path;
|
|
224
|
+
} catch (error) {
|
|
225
|
+
this.log.error(`Failed to upload metaconfig to S3: ${error.message}`, error);
|
|
226
|
+
throw this.#createError(`S3 upload failed: ${error.message}`, HTTP_INTERNAL_SERVER_ERROR);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Fetches existing Tokowaka configuration from S3 for a specific URL
|
|
232
|
+
* @param {string} url - Full URL (e.g., 'https://www.example.com/products/item')
|
|
233
|
+
* @param {boolean} isPreview - Whether to fetch from preview path (default: false)
|
|
165
234
|
* @returns {Promise<Object|null>} - Existing configuration object or null if not found
|
|
166
235
|
*/
|
|
167
|
-
async fetchConfig(
|
|
168
|
-
if (!hasText(
|
|
169
|
-
throw this.#createError('
|
|
236
|
+
async fetchConfig(url, isPreview = false) {
|
|
237
|
+
if (!hasText(url)) {
|
|
238
|
+
throw this.#createError('URL is required', HTTP_BAD_REQUEST);
|
|
170
239
|
}
|
|
171
240
|
|
|
172
|
-
const s3Path = getTokowakaConfigS3Path(
|
|
241
|
+
const s3Path = getTokowakaConfigS3Path(url, this.log, isPreview);
|
|
242
|
+
const bucketName = isPreview ? this.previewBucketName : this.deployBucketName;
|
|
173
243
|
|
|
174
244
|
try {
|
|
175
245
|
const command = new GetObjectCommand({
|
|
176
|
-
Bucket:
|
|
246
|
+
Bucket: bucketName,
|
|
177
247
|
Key: s3Path,
|
|
178
248
|
});
|
|
179
249
|
|
|
@@ -181,12 +251,12 @@ class TokowakaClient {
|
|
|
181
251
|
const bodyContents = await response.Body.transformToString();
|
|
182
252
|
const config = JSON.parse(bodyContents);
|
|
183
253
|
|
|
184
|
-
this.log.debug(`Successfully fetched existing Tokowaka config from s3://${
|
|
254
|
+
this.log.debug(`Successfully fetched existing Tokowaka config from s3://${bucketName}/${s3Path}`);
|
|
185
255
|
return config;
|
|
186
256
|
} catch (error) {
|
|
187
257
|
// If config doesn't exist (NoSuchKey), return null
|
|
188
258
|
if (error.name === 'NoSuchKey' || error.Code === 'NoSuchKey') {
|
|
189
|
-
this.log.debug(`No existing Tokowaka config found at s3://${
|
|
259
|
+
this.log.debug(`No existing Tokowaka config found at s3://${bucketName}/${s3Path}`);
|
|
190
260
|
return null;
|
|
191
261
|
}
|
|
192
262
|
|
|
@@ -198,9 +268,9 @@ class TokowakaClient {
|
|
|
198
268
|
|
|
199
269
|
/**
|
|
200
270
|
* Merges existing configuration with new configuration
|
|
201
|
-
*
|
|
271
|
+
* Checks patch key:
|
|
202
272
|
* - Patches are identified by opportunityId+suggestionId
|
|
203
|
-
* - Heading patches (no suggestionId) are identified by opportunityId
|
|
273
|
+
* - Heading patches (no suggestionId) are identified by opportunityId
|
|
204
274
|
* - If exists: updates the patch
|
|
205
275
|
* - If not exists: adds new patch to the array
|
|
206
276
|
* @param {Object} existingConfig - Existing configuration from S3
|
|
@@ -212,73 +282,55 @@ class TokowakaClient {
|
|
|
212
282
|
return newConfig;
|
|
213
283
|
}
|
|
214
284
|
|
|
215
|
-
|
|
216
|
-
const
|
|
285
|
+
const existingPatches = existingConfig.patches || [];
|
|
286
|
+
const newPatches = newConfig.patches || [];
|
|
287
|
+
|
|
288
|
+
const { patches: mergedPatches, updateCount, addCount } = mergePatches(
|
|
289
|
+
existingPatches,
|
|
290
|
+
newPatches,
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
this.log.debug(`Merged patches: ${updateCount} updated, ${addCount} added`);
|
|
294
|
+
|
|
295
|
+
return {
|
|
217
296
|
...existingConfig,
|
|
218
|
-
|
|
297
|
+
url: newConfig.url,
|
|
219
298
|
version: newConfig.version,
|
|
220
|
-
|
|
299
|
+
forceFail: newConfig.forceFail,
|
|
300
|
+
prerender: newConfig.prerender,
|
|
301
|
+
patches: mergedPatches,
|
|
221
302
|
};
|
|
222
|
-
|
|
223
|
-
// Merge optimizations for each URL path
|
|
224
|
-
Object.entries(newConfig.tokowakaOptimizations).forEach(([urlPath, newOptimization]) => {
|
|
225
|
-
const existingOptimization = mergedConfig.tokowakaOptimizations[urlPath];
|
|
226
|
-
|
|
227
|
-
if (!existingOptimization) {
|
|
228
|
-
// URL path doesn't exist in existing config, add it entirely
|
|
229
|
-
mergedConfig.tokowakaOptimizations[urlPath] = newOptimization;
|
|
230
|
-
this.log.debug(`Added new URL path: ${urlPath}`);
|
|
231
|
-
} else {
|
|
232
|
-
// URL path exists, merge patches
|
|
233
|
-
const existingPatches = existingOptimization.patches || [];
|
|
234
|
-
const newPatches = newOptimization.patches || [];
|
|
235
|
-
|
|
236
|
-
const { patches: mergedPatches, updateCount, addCount } = mergePatches(
|
|
237
|
-
existingPatches,
|
|
238
|
-
newPatches,
|
|
239
|
-
);
|
|
240
|
-
|
|
241
|
-
mergedConfig.tokowakaOptimizations[urlPath] = {
|
|
242
|
-
...existingOptimization,
|
|
243
|
-
prerender: newOptimization.prerender,
|
|
244
|
-
patches: mergedPatches,
|
|
245
|
-
};
|
|
246
|
-
|
|
247
|
-
this.log.debug(`Merged patches for ${urlPath}: ${updateCount} updated, ${addCount} added`);
|
|
248
|
-
}
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
return mergedConfig;
|
|
252
303
|
}
|
|
253
304
|
|
|
254
305
|
/**
|
|
255
|
-
* Uploads Tokowaka configuration to S3
|
|
256
|
-
* @param {string}
|
|
306
|
+
* Uploads Tokowaka configuration to S3 for a specific URL
|
|
307
|
+
* @param {string} url - Full URL (e.g., 'https://www.example.com/products/item')
|
|
257
308
|
* @param {Object} config - Tokowaka configuration object
|
|
258
309
|
* @param {boolean} isPreview - Whether to upload to preview path (default: false)
|
|
259
310
|
* @returns {Promise<string>} - S3 key of uploaded config
|
|
260
311
|
*/
|
|
261
|
-
async uploadConfig(
|
|
262
|
-
if (!hasText(
|
|
263
|
-
throw this.#createError('
|
|
312
|
+
async uploadConfig(url, config, isPreview = false) {
|
|
313
|
+
if (!hasText(url)) {
|
|
314
|
+
throw this.#createError('URL is required', HTTP_BAD_REQUEST);
|
|
264
315
|
}
|
|
265
316
|
|
|
266
317
|
if (!isNonEmptyObject(config)) {
|
|
267
318
|
throw this.#createError('Config object is required', HTTP_BAD_REQUEST);
|
|
268
319
|
}
|
|
269
320
|
|
|
270
|
-
const s3Path = getTokowakaConfigS3Path(
|
|
321
|
+
const s3Path = getTokowakaConfigS3Path(url, this.log, isPreview);
|
|
322
|
+
const bucketName = isPreview ? this.previewBucketName : this.deployBucketName;
|
|
271
323
|
|
|
272
324
|
try {
|
|
273
325
|
const command = new PutObjectCommand({
|
|
274
|
-
Bucket:
|
|
326
|
+
Bucket: bucketName,
|
|
275
327
|
Key: s3Path,
|
|
276
328
|
Body: JSON.stringify(config, null, 2),
|
|
277
329
|
ContentType: 'application/json',
|
|
278
330
|
});
|
|
279
331
|
|
|
280
332
|
await this.s3Client.send(command);
|
|
281
|
-
this.log.info(`Successfully uploaded Tokowaka config to s3://${
|
|
333
|
+
this.log.info(`Successfully uploaded Tokowaka config to s3://${bucketName}/${s3Path}`);
|
|
282
334
|
|
|
283
335
|
return s3Path;
|
|
284
336
|
} catch (error) {
|
|
@@ -288,19 +340,19 @@ class TokowakaClient {
|
|
|
288
340
|
}
|
|
289
341
|
|
|
290
342
|
/**
|
|
291
|
-
* Invalidates CDN cache for the Tokowaka config
|
|
343
|
+
* Invalidates CDN cache for the Tokowaka config for a specific URL
|
|
292
344
|
* Currently supports CloudFront only
|
|
293
|
-
* @param {string}
|
|
345
|
+
* @param {string} url - Full URL (e.g., 'https://www.example.com/products/item')
|
|
294
346
|
* @param {string} provider - CDN provider name (default: 'cloudfront')
|
|
295
347
|
* @param {boolean} isPreview - Whether to invalidate preview path (default: false)
|
|
296
348
|
* @returns {Promise<Object|null>} - CDN invalidation result or null if skipped
|
|
297
349
|
*/
|
|
298
|
-
async invalidateCdnCache(
|
|
299
|
-
if (!hasText(
|
|
300
|
-
throw this.#createError('
|
|
350
|
+
async invalidateCdnCache(url, provider, isPreview = false) {
|
|
351
|
+
if (!hasText(url) || !hasText(provider)) {
|
|
352
|
+
throw this.#createError('URL and provider are required', HTTP_BAD_REQUEST);
|
|
301
353
|
}
|
|
302
354
|
try {
|
|
303
|
-
const pathsToInvalidate = [`/${getTokowakaConfigS3Path(
|
|
355
|
+
const pathsToInvalidate = [`/${getTokowakaConfigS3Path(url, this.log, isPreview)}`];
|
|
304
356
|
this.log.debug(`Invalidating CDN cache for ${pathsToInvalidate.length} paths via ${provider}`);
|
|
305
357
|
const cdnClient = this.cdnClientRegistry.getClient(provider);
|
|
306
358
|
if (!cdnClient) {
|
|
@@ -321,23 +373,17 @@ class TokowakaClient {
|
|
|
321
373
|
|
|
322
374
|
/**
|
|
323
375
|
* Deploys suggestions to Tokowaka by generating config and uploading to S3
|
|
376
|
+
* Now creates one file per URL instead of a single file with all URLs
|
|
377
|
+
* Also creates/updates domain-level metadata if needed
|
|
324
378
|
* @param {Object} site - Site entity
|
|
325
379
|
* @param {Object} opportunity - Opportunity entity
|
|
326
380
|
* @param {Array} suggestions - Array of suggestion entities to deploy
|
|
327
381
|
* @returns {Promise<Object>} - Deployment result with succeeded/failed suggestions
|
|
328
382
|
*/
|
|
329
383
|
async deploySuggestions(site, opportunity, suggestions) {
|
|
330
|
-
// Get site's Tokowaka API key
|
|
331
|
-
const { apiKey } = site.getConfig()?.getTokowakaConfig() || {};
|
|
332
|
-
|
|
333
|
-
if (!hasText(apiKey)) {
|
|
334
|
-
throw this.#createError(
|
|
335
|
-
'Site does not have a Tokowaka API key configured. Please onboard the site to Tokowaka first.',
|
|
336
|
-
HTTP_BAD_REQUEST,
|
|
337
|
-
);
|
|
338
|
-
}
|
|
339
|
-
|
|
340
384
|
const opportunityType = opportunity.getType();
|
|
385
|
+
const baseURL = getEffectiveBaseURL(site);
|
|
386
|
+
const siteId = site.getId();
|
|
341
387
|
const mapper = this.mapperRegistry.getMapper(opportunityType);
|
|
342
388
|
if (!mapper) {
|
|
343
389
|
throw this.#createError(
|
|
@@ -366,42 +412,67 @@ class TokowakaClient {
|
|
|
366
412
|
};
|
|
367
413
|
}
|
|
368
414
|
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
const existingConfig = await this.fetchConfig(apiKey);
|
|
415
|
+
// Group suggestions by URL
|
|
416
|
+
const suggestionsByUrl = groupSuggestionsByUrlPath(eligibleSuggestions, baseURL, this.log);
|
|
372
417
|
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
site,
|
|
377
|
-
opportunity,
|
|
378
|
-
eligibleSuggestions,
|
|
379
|
-
);
|
|
418
|
+
// Check/create domain-level metaconfig (only need to do this once per deployment)
|
|
419
|
+
const firstUrl = new URL(Object.keys(suggestionsByUrl)[0], baseURL).toString();
|
|
420
|
+
let metaconfig = await this.fetchMetaconfig(firstUrl);
|
|
380
421
|
|
|
381
|
-
if (
|
|
382
|
-
this.log.
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
422
|
+
if (!metaconfig) {
|
|
423
|
+
this.log.info('Creating domain-level metaconfig');
|
|
424
|
+
metaconfig = {
|
|
425
|
+
siteId,
|
|
426
|
+
prerender: mapper.requiresPrerender(),
|
|
386
427
|
};
|
|
428
|
+
await this.uploadMetaconfig(firstUrl, metaconfig);
|
|
429
|
+
} else {
|
|
430
|
+
this.log.debug('Domain-level metaconfig already exists');
|
|
387
431
|
}
|
|
388
432
|
|
|
389
|
-
//
|
|
390
|
-
const
|
|
433
|
+
// Process each URL separately
|
|
434
|
+
const s3Paths = [];
|
|
435
|
+
const cdnInvalidations = [];
|
|
391
436
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
437
|
+
for (const [urlPath, urlSuggestions] of Object.entries(suggestionsByUrl)) {
|
|
438
|
+
const fullUrl = new URL(urlPath, baseURL).toString();
|
|
439
|
+
this.log.debug(`Processing ${urlSuggestions.length} suggestions for URL: ${fullUrl}`);
|
|
395
440
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
441
|
+
// Fetch existing configuration for this URL from S3
|
|
442
|
+
// eslint-disable-next-line no-await-in-loop
|
|
443
|
+
const existingConfig = await this.fetchConfig(fullUrl);
|
|
444
|
+
|
|
445
|
+
// Generate configuration for this URL with eligible suggestions only
|
|
446
|
+
const newConfig = this.generateConfig(fullUrl, opportunity, urlSuggestions);
|
|
447
|
+
|
|
448
|
+
if (!newConfig || !newConfig.patches || newConfig.patches.length === 0) {
|
|
449
|
+
this.log.warn(`No eligible suggestions to deploy for URL: ${fullUrl}`);
|
|
450
|
+
// eslint-disable-next-line no-continue
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Merge with existing config for this URL
|
|
455
|
+
const config = this.mergeConfigs(existingConfig, newConfig);
|
|
456
|
+
|
|
457
|
+
// Upload to S3
|
|
458
|
+
// eslint-disable-next-line no-await-in-loop
|
|
459
|
+
const s3Path = await this.uploadConfig(fullUrl, config);
|
|
460
|
+
s3Paths.push(s3Path);
|
|
461
|
+
|
|
462
|
+
// Invalidate CDN cache (non-blocking, failures are logged but don't fail deployment)
|
|
463
|
+
// eslint-disable-next-line no-await-in-loop
|
|
464
|
+
const cdnInvalidationResult = await this.invalidateCdnCache(
|
|
465
|
+
fullUrl,
|
|
466
|
+
this.env.TOKOWAKA_CDN_PROVIDER,
|
|
467
|
+
);
|
|
468
|
+
cdnInvalidations.push(cdnInvalidationResult);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
this.log.info(`Uploaded Tokowaka configs for ${s3Paths.length} URLs`);
|
|
401
472
|
|
|
402
473
|
return {
|
|
403
|
-
|
|
404
|
-
|
|
474
|
+
s3Paths,
|
|
475
|
+
cdnInvalidations,
|
|
405
476
|
succeededSuggestions: eligibleSuggestions,
|
|
406
477
|
failedSuggestions: ineligibleSuggestions,
|
|
407
478
|
};
|
|
@@ -409,23 +480,15 @@ class TokowakaClient {
|
|
|
409
480
|
|
|
410
481
|
/**
|
|
411
482
|
* Rolls back deployed suggestions by removing their patches from the configuration
|
|
483
|
+
* Now updates one file per URL instead of a single file with all URLs
|
|
412
484
|
* @param {Object} site - Site entity
|
|
413
485
|
* @param {Object} opportunity - Opportunity entity
|
|
414
486
|
* @param {Array} suggestions - Array of suggestion entities to rollback
|
|
415
487
|
* @returns {Promise<Object>} - Rollback result with succeeded/failed suggestions
|
|
416
488
|
*/
|
|
417
489
|
async rollbackSuggestions(site, opportunity, suggestions) {
|
|
418
|
-
// Get site's Tokowaka API key
|
|
419
|
-
const { apiKey } = site.getConfig()?.getTokowakaConfig() || {};
|
|
420
|
-
|
|
421
|
-
if (!hasText(apiKey)) {
|
|
422
|
-
throw this.#createError(
|
|
423
|
-
'Site does not have a Tokowaka API key configured. Please onboard the site to Tokowaka first.',
|
|
424
|
-
HTTP_BAD_REQUEST,
|
|
425
|
-
);
|
|
426
|
-
}
|
|
427
|
-
|
|
428
490
|
const opportunityType = opportunity.getType();
|
|
491
|
+
const baseURL = getEffectiveBaseURL(site);
|
|
429
492
|
const mapper = this.mapperRegistry.getMapper(opportunityType);
|
|
430
493
|
if (!mapper) {
|
|
431
494
|
throw this.#createError(
|
|
@@ -455,78 +518,89 @@ class TokowakaClient {
|
|
|
455
518
|
};
|
|
456
519
|
}
|
|
457
520
|
|
|
458
|
-
//
|
|
459
|
-
|
|
460
|
-
const existingConfig = await this.fetchConfig(apiKey);
|
|
521
|
+
// Group suggestions by URL
|
|
522
|
+
const suggestionsByUrl = groupSuggestionsByUrlPath(eligibleSuggestions, baseURL, this.log);
|
|
461
523
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
failedSuggestions: eligibleSuggestions.map((suggestion) => ({
|
|
467
|
-
suggestion,
|
|
468
|
-
reason: 'No existing configuration found',
|
|
469
|
-
})),
|
|
470
|
-
};
|
|
471
|
-
}
|
|
524
|
+
// Process each URL separately
|
|
525
|
+
const s3Paths = [];
|
|
526
|
+
const cdnInvalidations = [];
|
|
527
|
+
let totalRemovedCount = 0;
|
|
472
528
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
existingConfig,
|
|
477
|
-
suggestionIdsToRemove,
|
|
478
|
-
opportunity.getId(),
|
|
479
|
-
);
|
|
529
|
+
for (const [urlPath, urlSuggestions] of Object.entries(suggestionsByUrl)) {
|
|
530
|
+
const fullUrl = new URL(urlPath, baseURL).toString();
|
|
531
|
+
this.log.debug(`Rolling back ${urlSuggestions.length} suggestions for URL: ${fullUrl}`);
|
|
480
532
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
succeededSuggestions: [],
|
|
485
|
-
failedSuggestions: eligibleSuggestions.map((suggestion) => ({
|
|
486
|
-
suggestion,
|
|
487
|
-
reason: 'No patches found for this suggestion in the current configuration',
|
|
488
|
-
})),
|
|
489
|
-
};
|
|
490
|
-
}
|
|
533
|
+
// Fetch existing configuration for this URL from S3
|
|
534
|
+
// eslint-disable-next-line no-await-in-loop
|
|
535
|
+
const existingConfig = await this.fetchConfig(fullUrl);
|
|
491
536
|
|
|
492
|
-
|
|
537
|
+
if (!existingConfig || !existingConfig.patches) {
|
|
538
|
+
this.log.warn(`No existing configuration found for URL: ${fullUrl}`);
|
|
539
|
+
// eslint-disable-next-line no-continue
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
493
542
|
|
|
494
|
-
|
|
495
|
-
|
|
543
|
+
// Extract suggestion IDs to remove for this URL
|
|
544
|
+
const suggestionIdsToRemove = urlSuggestions.map((s) => s.getId());
|
|
496
545
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
546
|
+
// Use mapper to remove patches
|
|
547
|
+
const updatedConfig = mapper.rollbackPatches(
|
|
548
|
+
existingConfig,
|
|
549
|
+
suggestionIdsToRemove,
|
|
550
|
+
opportunity.getId(),
|
|
551
|
+
);
|
|
500
552
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
553
|
+
if (updatedConfig.removedCount === 0) {
|
|
554
|
+
this.log.warn(`No patches found for URL: ${fullUrl}`);
|
|
555
|
+
// eslint-disable-next-line no-continue
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
this.log.info(`Removed ${updatedConfig.removedCount} patches for URL: ${fullUrl}`);
|
|
560
|
+
totalRemovedCount += updatedConfig.removedCount;
|
|
561
|
+
|
|
562
|
+
// Remove the removedCount property before uploading
|
|
563
|
+
delete updatedConfig.removedCount;
|
|
564
|
+
|
|
565
|
+
// Upload updated config to S3 for this URL
|
|
566
|
+
// eslint-disable-next-line no-await-in-loop
|
|
567
|
+
const s3Path = await this.uploadConfig(fullUrl, updatedConfig);
|
|
568
|
+
s3Paths.push(s3Path);
|
|
569
|
+
|
|
570
|
+
// Invalidate CDN cache (non-blocking, failures are logged but don't fail rollback)
|
|
571
|
+
// eslint-disable-next-line no-await-in-loop
|
|
572
|
+
const cdnInvalidationResult = await this.invalidateCdnCache(
|
|
573
|
+
fullUrl,
|
|
574
|
+
this.env.TOKOWAKA_CDN_PROVIDER,
|
|
575
|
+
);
|
|
576
|
+
cdnInvalidations.push(cdnInvalidationResult);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
this.log.info(`Updated Tokowaka configs for ${s3Paths.length} URLs, removed ${totalRemovedCount} patches total`);
|
|
506
580
|
|
|
507
581
|
return {
|
|
508
|
-
|
|
509
|
-
|
|
582
|
+
s3Paths,
|
|
583
|
+
cdnInvalidations,
|
|
510
584
|
succeededSuggestions: eligibleSuggestions,
|
|
511
585
|
failedSuggestions: ineligibleSuggestions,
|
|
512
|
-
removedPatchesCount:
|
|
586
|
+
removedPatchesCount: totalRemovedCount,
|
|
513
587
|
};
|
|
514
588
|
}
|
|
515
589
|
|
|
516
590
|
/**
|
|
517
591
|
* Previews suggestions by generating config and uploading to preview path
|
|
518
|
-
*
|
|
592
|
+
* All suggestions must belong to the same URL
|
|
519
593
|
* @param {Object} site - Site entity
|
|
520
594
|
* @param {Object} opportunity - Opportunity entity
|
|
521
|
-
* @param {Array} suggestions - Array of suggestion entities to preview
|
|
595
|
+
* @param {Array} suggestions - Array of suggestion entities to preview (must be same URL)
|
|
522
596
|
* @param {Object} options - Optional configuration for HTML fetching
|
|
523
597
|
* @returns {Promise<Object>} - Preview result with config and succeeded/failed suggestions
|
|
524
598
|
*/
|
|
525
599
|
async previewSuggestions(site, opportunity, suggestions, options = {}) {
|
|
526
|
-
// Get site's
|
|
527
|
-
const {
|
|
600
|
+
// Get site's forwarded host for preview
|
|
601
|
+
const { forwardedHost, apiKey } = site.getConfig()?.getTokowakaConfig() || {};
|
|
528
602
|
|
|
529
|
-
if (!hasText(
|
|
603
|
+
if (!hasText(forwardedHost) || !hasText(apiKey)) {
|
|
530
604
|
throw this.#createError(
|
|
531
605
|
'Site does not have a Tokowaka API key or forwarded host configured. '
|
|
532
606
|
+ 'Please onboard the site to Tokowaka first.',
|
|
@@ -573,19 +647,21 @@ class TokowakaClient {
|
|
|
573
647
|
};
|
|
574
648
|
}
|
|
575
649
|
|
|
576
|
-
//
|
|
577
|
-
|
|
578
|
-
|
|
650
|
+
// Get the preview URL from the first suggestion
|
|
651
|
+
const previewUrl = eligibleSuggestions[0].getData()?.url;
|
|
652
|
+
if (!hasText(previewUrl)) {
|
|
653
|
+
throw this.#createError('Preview URL not found in suggestion data', HTTP_BAD_REQUEST);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Fetch existing deployed configuration for this URL from production S3
|
|
657
|
+
this.log.debug(`Fetching existing deployed Tokowaka config for URL: ${previewUrl}`);
|
|
658
|
+
const existingConfig = await this.fetchConfig(previewUrl, false);
|
|
579
659
|
|
|
580
660
|
// Generate configuration with eligible preview suggestions
|
|
581
|
-
this.log.debug(`Generating preview Tokowaka config for
|
|
582
|
-
const newConfig = this.generateConfig(
|
|
583
|
-
site,
|
|
584
|
-
opportunity,
|
|
585
|
-
eligibleSuggestions,
|
|
586
|
-
);
|
|
661
|
+
this.log.debug(`Generating preview Tokowaka config for opportunity ${opportunity.getId()}`);
|
|
662
|
+
const newConfig = this.generateConfig(previewUrl, opportunity, eligibleSuggestions);
|
|
587
663
|
|
|
588
|
-
if (
|
|
664
|
+
if (!newConfig || !newConfig.patches || newConfig.patches.length === 0) {
|
|
589
665
|
this.log.warn('No eligible suggestions to preview');
|
|
590
666
|
return {
|
|
591
667
|
config: null,
|
|
@@ -594,50 +670,30 @@ class TokowakaClient {
|
|
|
594
670
|
};
|
|
595
671
|
}
|
|
596
672
|
|
|
597
|
-
// Get the preview URL from the first suggestion
|
|
598
|
-
const previewUrl = eligibleSuggestions[0].getData()?.url;
|
|
599
|
-
|
|
600
673
|
// Merge with existing deployed config to include already-deployed patches for this URL
|
|
601
674
|
let config = newConfig;
|
|
602
|
-
if (existingConfig &&
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
// Create a filtered existing config with only the URL being previewed
|
|
616
|
-
const filteredExistingConfig = {
|
|
617
|
-
...existingConfig,
|
|
618
|
-
tokowakaOptimizations: {
|
|
619
|
-
[urlPath]: existingUrlOptimization,
|
|
620
|
-
},
|
|
621
|
-
};
|
|
622
|
-
|
|
623
|
-
// Merge the existing deployed patches with new preview suggestions
|
|
624
|
-
config = this.mergeConfigs(filteredExistingConfig, newConfig);
|
|
625
|
-
|
|
626
|
-
this.log.debug(
|
|
627
|
-
`Preview config now has ${config.tokowakaOptimizations[urlPath].patches.length} total patches`,
|
|
628
|
-
);
|
|
629
|
-
} else {
|
|
630
|
-
this.log.info(`No deployed patches found for ${urlPath}, using only preview suggestions`);
|
|
631
|
-
}
|
|
675
|
+
if (existingConfig && existingConfig.patches?.length > 0) {
|
|
676
|
+
this.log.info(
|
|
677
|
+
`Found ${existingConfig.patches.length} deployed patches, merging with preview suggestions`,
|
|
678
|
+
);
|
|
679
|
+
|
|
680
|
+
// Merge the existing deployed patches with new preview suggestions
|
|
681
|
+
config = this.mergeConfigs(existingConfig, newConfig);
|
|
682
|
+
|
|
683
|
+
this.log.debug(
|
|
684
|
+
`Preview config now has ${config.patches.length} total patches`,
|
|
685
|
+
);
|
|
686
|
+
} else {
|
|
687
|
+
this.log.info('No deployed patches found, using only preview suggestions');
|
|
632
688
|
}
|
|
633
689
|
|
|
634
|
-
// Upload to preview S3 path
|
|
690
|
+
// Upload to preview S3 path for this URL
|
|
635
691
|
this.log.info(`Uploading preview Tokowaka config with ${eligibleSuggestions.length} new suggestions`);
|
|
636
|
-
const s3Path = await this.uploadConfig(
|
|
692
|
+
const s3Path = await this.uploadConfig(previewUrl, config, true);
|
|
637
693
|
|
|
638
694
|
// Invalidate CDN cache for preview path
|
|
639
695
|
const cdnInvalidationResult = await this.invalidateCdnCache(
|
|
640
|
-
|
|
696
|
+
previewUrl,
|
|
641
697
|
this.env.TOKOWAKA_CDN_PROVIDER,
|
|
642
698
|
true,
|
|
643
699
|
);
|
|
@@ -646,36 +702,34 @@ class TokowakaClient {
|
|
|
646
702
|
let originalHtml = null;
|
|
647
703
|
let optimizedHtml = null;
|
|
648
704
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
);
|
|
678
|
-
}
|
|
705
|
+
try {
|
|
706
|
+
// Fetch original HTML (without preview)
|
|
707
|
+
originalHtml = await fetchHtmlWithWarmup(
|
|
708
|
+
previewUrl,
|
|
709
|
+
apiKey,
|
|
710
|
+
forwardedHost,
|
|
711
|
+
tokowakaEdgeUrl,
|
|
712
|
+
this.log,
|
|
713
|
+
false,
|
|
714
|
+
options,
|
|
715
|
+
);
|
|
716
|
+
// Then fetch optimized HTML (with preview)
|
|
717
|
+
optimizedHtml = await fetchHtmlWithWarmup(
|
|
718
|
+
previewUrl,
|
|
719
|
+
apiKey,
|
|
720
|
+
forwardedHost,
|
|
721
|
+
tokowakaEdgeUrl,
|
|
722
|
+
this.log,
|
|
723
|
+
true,
|
|
724
|
+
options,
|
|
725
|
+
);
|
|
726
|
+
this.log.info('Successfully fetched both original and optimized HTML for preview');
|
|
727
|
+
} catch (error) {
|
|
728
|
+
this.log.error(`Failed to fetch HTML for preview: ${error.message}`);
|
|
729
|
+
throw this.#createError(
|
|
730
|
+
`Preview failed: Unable to fetch HTML - ${error.message}`,
|
|
731
|
+
HTTP_INTERNAL_SERVER_ERROR,
|
|
732
|
+
);
|
|
679
733
|
}
|
|
680
734
|
|
|
681
735
|
return {
|