@adobe/spacecat-shared-tokowaka-client 1.0.4 → 1.0.6
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 +14 -0
- package/package.json +3 -3
- package/src/index.js +202 -12
- package/src/utils/custom-html-utils.js +194 -0
- package/src/utils/s3-utils.js +5 -1
- package/test/index.test.js +396 -2
- package/test/mappers/headings-mapper.test.js +0 -2
- package/test/utils/html-utils.test.js +434 -0
- package/test/mappers/faq-mapper.test.js.backup +0 -1264
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [@adobe/spacecat-shared-tokowaka-client-v1.0.6](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.0.5...@adobe/spacecat-shared-tokowaka-client-v1.0.6) (2025-11-22)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **deps:** update external fixes ([#1162](https://github.com/adobe/spacecat-shared/issues/1162)) ([f0152c5](https://github.com/adobe/spacecat-shared/commit/f0152c5ecddb75b6b3c6e2f0d756d5fb04171dd3))
|
|
7
|
+
|
|
8
|
+
# [@adobe/spacecat-shared-tokowaka-client-v1.0.5](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.0.4...@adobe/spacecat-shared-tokowaka-client-v1.0.5) (2025-11-21)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* edge preview feature for Tokowaka ([#1124](https://github.com/adobe/spacecat-shared/issues/1124)) ([cd2a06f](https://github.com/adobe/spacecat-shared/commit/cd2a06f62e3ca9e8c2b87e1810d420a06a002526))
|
|
14
|
+
|
|
1
15
|
# [@adobe/spacecat-shared-tokowaka-client-v1.0.4](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.0.3...@adobe/spacecat-shared-tokowaka-client-v1.0.4) (2025-11-15)
|
|
2
16
|
|
|
3
17
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adobe/spacecat-shared-tokowaka-client",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "Tokowaka Client for SpaceCat - Edge optimization config management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@adobe/spacecat-shared-utils": "1.66.1",
|
|
38
|
-
"@aws-sdk/client-cloudfront": "3.
|
|
39
|
-
"@aws-sdk/client-s3": "3.
|
|
38
|
+
"@aws-sdk/client-cloudfront": "3.937.0",
|
|
39
|
+
"@aws-sdk/client-s3": "3.937.0",
|
|
40
40
|
"mdast-util-from-markdown": "2.0.2",
|
|
41
41
|
"mdast-util-to-hast": "12.3.0"
|
|
42
42
|
},
|
package/src/index.js
CHANGED
|
@@ -18,6 +18,7 @@ import { mergePatches } from './utils/patch-utils.js';
|
|
|
18
18
|
import { getTokowakaConfigS3Path } 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
22
|
|
|
22
23
|
const HTTP_BAD_REQUEST = 400;
|
|
23
24
|
const HTTP_INTERNAL_SERVER_ERROR = 500;
|
|
@@ -34,7 +35,10 @@ class TokowakaClient {
|
|
|
34
35
|
*/
|
|
35
36
|
static createFrom(context) {
|
|
36
37
|
const { env, log = console, s3 } = context;
|
|
37
|
-
const {
|
|
38
|
+
const {
|
|
39
|
+
TOKOWAKA_SITE_CONFIG_BUCKET: bucketName,
|
|
40
|
+
TOKOWAKA_PREVIEW_BUCKET: previewBucketName,
|
|
41
|
+
} = env;
|
|
38
42
|
|
|
39
43
|
if (context.tokowakaClient) {
|
|
40
44
|
return context.tokowakaClient;
|
|
@@ -43,6 +47,7 @@ class TokowakaClient {
|
|
|
43
47
|
// s3ClientWrapper puts s3Client at context.s3.s3Client, so check both locations
|
|
44
48
|
const client = new TokowakaClient({
|
|
45
49
|
bucketName,
|
|
50
|
+
previewBucketName,
|
|
46
51
|
s3Client: s3?.s3Client,
|
|
47
52
|
env,
|
|
48
53
|
}, log);
|
|
@@ -54,11 +59,14 @@ class TokowakaClient {
|
|
|
54
59
|
* Constructor
|
|
55
60
|
* @param {Object} config - Configuration object
|
|
56
61
|
* @param {string} config.bucketName - S3 bucket name for configs
|
|
62
|
+
* @param {string} config.previewBucketName - S3 bucket name for preview configs
|
|
57
63
|
* @param {Object} config.s3Client - AWS S3 client
|
|
58
64
|
* @param {Object} config.env - Environment variables (for CDN credentials)
|
|
59
65
|
* @param {Object} log - Logger instance
|
|
60
66
|
*/
|
|
61
|
-
constructor({
|
|
67
|
+
constructor({
|
|
68
|
+
bucketName, previewBucketName, s3Client, env = {},
|
|
69
|
+
}, log) {
|
|
62
70
|
this.log = log;
|
|
63
71
|
|
|
64
72
|
if (!hasText(bucketName)) {
|
|
@@ -69,7 +77,8 @@ class TokowakaClient {
|
|
|
69
77
|
throw this.#createError('S3 client is required', HTTP_BAD_REQUEST);
|
|
70
78
|
}
|
|
71
79
|
|
|
72
|
-
this.
|
|
80
|
+
this.deployBucketName = bucketName;
|
|
81
|
+
this.previewBucketName = previewBucketName;
|
|
73
82
|
this.s3Client = s3Client;
|
|
74
83
|
this.env = env;
|
|
75
84
|
|
|
@@ -164,7 +173,7 @@ class TokowakaClient {
|
|
|
164
173
|
|
|
165
174
|
try {
|
|
166
175
|
const command = new GetObjectCommand({
|
|
167
|
-
Bucket: this.
|
|
176
|
+
Bucket: this.deployBucketName,
|
|
168
177
|
Key: s3Path,
|
|
169
178
|
});
|
|
170
179
|
|
|
@@ -172,12 +181,12 @@ class TokowakaClient {
|
|
|
172
181
|
const bodyContents = await response.Body.transformToString();
|
|
173
182
|
const config = JSON.parse(bodyContents);
|
|
174
183
|
|
|
175
|
-
this.log.debug(`Successfully fetched existing Tokowaka config from s3://${this.
|
|
184
|
+
this.log.debug(`Successfully fetched existing Tokowaka config from s3://${this.deployBucketName}/${s3Path}`);
|
|
176
185
|
return config;
|
|
177
186
|
} catch (error) {
|
|
178
187
|
// If config doesn't exist (NoSuchKey), return null
|
|
179
188
|
if (error.name === 'NoSuchKey' || error.Code === 'NoSuchKey') {
|
|
180
|
-
this.log.debug(`No existing Tokowaka config found at s3://${this.
|
|
189
|
+
this.log.debug(`No existing Tokowaka config found at s3://${this.deployBucketName}/${s3Path}`);
|
|
181
190
|
return null;
|
|
182
191
|
}
|
|
183
192
|
|
|
@@ -246,9 +255,10 @@ class TokowakaClient {
|
|
|
246
255
|
* Uploads Tokowaka configuration to S3
|
|
247
256
|
* @param {string} siteTokowakaKey - Tokowaka API key (used as S3 key prefix)
|
|
248
257
|
* @param {Object} config - Tokowaka configuration object
|
|
258
|
+
* @param {boolean} isPreview - Whether to upload to preview path (default: false)
|
|
249
259
|
* @returns {Promise<string>} - S3 key of uploaded config
|
|
250
260
|
*/
|
|
251
|
-
async uploadConfig(siteTokowakaKey, config) {
|
|
261
|
+
async uploadConfig(siteTokowakaKey, config, isPreview = false) {
|
|
252
262
|
if (!hasText(siteTokowakaKey)) {
|
|
253
263
|
throw this.#createError('Tokowaka API key is required', HTTP_BAD_REQUEST);
|
|
254
264
|
}
|
|
@@ -257,18 +267,18 @@ class TokowakaClient {
|
|
|
257
267
|
throw this.#createError('Config object is required', HTTP_BAD_REQUEST);
|
|
258
268
|
}
|
|
259
269
|
|
|
260
|
-
const s3Path = getTokowakaConfigS3Path(siteTokowakaKey);
|
|
270
|
+
const s3Path = getTokowakaConfigS3Path(siteTokowakaKey, isPreview);
|
|
261
271
|
|
|
262
272
|
try {
|
|
263
273
|
const command = new PutObjectCommand({
|
|
264
|
-
Bucket: this.
|
|
274
|
+
Bucket: isPreview ? this.previewBucketName : this.deployBucketName,
|
|
265
275
|
Key: s3Path,
|
|
266
276
|
Body: JSON.stringify(config, null, 2),
|
|
267
277
|
ContentType: 'application/json',
|
|
268
278
|
});
|
|
269
279
|
|
|
270
280
|
await this.s3Client.send(command);
|
|
271
|
-
this.log.info(`Successfully uploaded Tokowaka config to s3://${this.
|
|
281
|
+
this.log.info(`Successfully uploaded Tokowaka config to s3://${this.deployBucketName}/${s3Path}`);
|
|
272
282
|
|
|
273
283
|
return s3Path;
|
|
274
284
|
} catch (error) {
|
|
@@ -282,14 +292,15 @@ class TokowakaClient {
|
|
|
282
292
|
* Currently supports CloudFront only
|
|
283
293
|
* @param {string} apiKey - Tokowaka API key
|
|
284
294
|
* @param {string} provider - CDN provider name (default: 'cloudfront')
|
|
295
|
+
* @param {boolean} isPreview - Whether to invalidate preview path (default: false)
|
|
285
296
|
* @returns {Promise<Object|null>} - CDN invalidation result or null if skipped
|
|
286
297
|
*/
|
|
287
|
-
async invalidateCdnCache(apiKey, provider) {
|
|
298
|
+
async invalidateCdnCache(apiKey, provider, isPreview = false) {
|
|
288
299
|
if (!hasText(apiKey) || !hasText(provider)) {
|
|
289
300
|
throw this.#createError('Tokowaka API key and provider are required', HTTP_BAD_REQUEST);
|
|
290
301
|
}
|
|
291
302
|
try {
|
|
292
|
-
const pathsToInvalidate = [`/${getTokowakaConfigS3Path(apiKey)}`];
|
|
303
|
+
const pathsToInvalidate = [`/${getTokowakaConfigS3Path(apiKey, isPreview)}`];
|
|
293
304
|
this.log.debug(`Invalidating CDN cache for ${pathsToInvalidate.length} paths via ${provider}`);
|
|
294
305
|
const cdnClient = this.cdnClientRegistry.getClient(provider);
|
|
295
306
|
if (!cdnClient) {
|
|
@@ -395,6 +406,185 @@ class TokowakaClient {
|
|
|
395
406
|
failedSuggestions: ineligibleSuggestions,
|
|
396
407
|
};
|
|
397
408
|
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Previews suggestions by generating config and uploading to preview path
|
|
412
|
+
* Unlike deploySuggestions, this does NOT merge with existing config
|
|
413
|
+
* @param {Object} site - Site entity
|
|
414
|
+
* @param {Object} opportunity - Opportunity entity
|
|
415
|
+
* @param {Array} suggestions - Array of suggestion entities to preview
|
|
416
|
+
* @param {Object} options - Optional configuration for HTML fetching
|
|
417
|
+
* @returns {Promise<Object>} - Preview result with config and succeeded/failed suggestions
|
|
418
|
+
*/
|
|
419
|
+
async previewSuggestions(site, opportunity, suggestions, options = {}) {
|
|
420
|
+
// Get site's Tokowaka API key
|
|
421
|
+
const { apiKey, forwardedHost } = site.getConfig()?.getTokowakaConfig() || {};
|
|
422
|
+
|
|
423
|
+
if (!hasText(apiKey) || !hasText(forwardedHost)) {
|
|
424
|
+
throw this.#createError(
|
|
425
|
+
'Site does not have a Tokowaka API key or forwarded host configured. '
|
|
426
|
+
+ 'Please onboard the site to Tokowaka first.',
|
|
427
|
+
HTTP_BAD_REQUEST,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const opportunityType = opportunity.getType();
|
|
432
|
+
const mapper = this.mapperRegistry.getMapper(opportunityType);
|
|
433
|
+
if (!mapper) {
|
|
434
|
+
throw this.#createError(
|
|
435
|
+
`No mapper found for opportunity type: ${opportunityType}. `
|
|
436
|
+
+ `Supported types: ${this.mapperRegistry.getSupportedOpportunityTypes().join(', ')}`,
|
|
437
|
+
HTTP_NOT_IMPLEMENTED,
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// TOKOWAKA_EDGE_URL is mandatory for preview
|
|
442
|
+
const tokowakaEdgeUrl = this.env.TOKOWAKA_EDGE_URL;
|
|
443
|
+
if (!hasText(tokowakaEdgeUrl)) {
|
|
444
|
+
throw this.#createError(
|
|
445
|
+
'TOKOWAKA_EDGE_URL is required for preview functionality',
|
|
446
|
+
HTTP_INTERNAL_SERVER_ERROR,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Validate which suggestions can be deployed using mapper's canDeploy method
|
|
451
|
+
const {
|
|
452
|
+
eligible: eligibleSuggestions,
|
|
453
|
+
ineligible: ineligibleSuggestions,
|
|
454
|
+
} = filterEligibleSuggestions(suggestions, mapper);
|
|
455
|
+
|
|
456
|
+
this.log.debug(
|
|
457
|
+
`Previewing ${eligibleSuggestions.length} eligible suggestions `
|
|
458
|
+
+ `(${ineligibleSuggestions.length} ineligible)`,
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
if (eligibleSuggestions.length === 0) {
|
|
462
|
+
this.log.warn('No eligible suggestions to preview');
|
|
463
|
+
return {
|
|
464
|
+
config: null,
|
|
465
|
+
succeededSuggestions: [],
|
|
466
|
+
failedSuggestions: ineligibleSuggestions,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Fetch existing deployed configuration from production S3
|
|
471
|
+
this.log.debug(`Fetching existing deployed Tokowaka config for site ${site.getId()}`);
|
|
472
|
+
const existingConfig = await this.fetchConfig(apiKey, false);
|
|
473
|
+
|
|
474
|
+
// Generate configuration with eligible preview suggestions
|
|
475
|
+
this.log.debug(`Generating preview Tokowaka config for site ${site.getId()}, opportunity ${opportunity.getId()}`);
|
|
476
|
+
const newConfig = this.generateConfig(
|
|
477
|
+
site,
|
|
478
|
+
opportunity,
|
|
479
|
+
eligibleSuggestions,
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
if (Object.keys(newConfig.tokowakaOptimizations).length === 0) {
|
|
483
|
+
this.log.warn('No eligible suggestions to preview');
|
|
484
|
+
return {
|
|
485
|
+
config: null,
|
|
486
|
+
succeededSuggestions: [],
|
|
487
|
+
failedSuggestions: suggestions,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Get the preview URL from the first suggestion
|
|
492
|
+
const previewUrl = eligibleSuggestions[0].getData()?.url;
|
|
493
|
+
|
|
494
|
+
// Merge with existing deployed config to include already-deployed patches for this URL
|
|
495
|
+
let config = newConfig;
|
|
496
|
+
if (existingConfig && previewUrl) {
|
|
497
|
+
// Extract the URL path from the preview URL
|
|
498
|
+
const urlPath = new URL(previewUrl).pathname;
|
|
499
|
+
|
|
500
|
+
// Check if there are already deployed patches for this URL
|
|
501
|
+
const existingUrlOptimization = existingConfig.tokowakaOptimizations?.[urlPath];
|
|
502
|
+
|
|
503
|
+
if (existingUrlOptimization && existingUrlOptimization.patches?.length > 0) {
|
|
504
|
+
this.log.info(
|
|
505
|
+
`Found ${existingUrlOptimization.patches.length} deployed patches for ${urlPath}, `
|
|
506
|
+
+ 'merging with preview suggestions',
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
// Create a filtered existing config with only the URL being previewed
|
|
510
|
+
const filteredExistingConfig = {
|
|
511
|
+
...existingConfig,
|
|
512
|
+
tokowakaOptimizations: {
|
|
513
|
+
[urlPath]: existingUrlOptimization,
|
|
514
|
+
},
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
// Merge the existing deployed patches with new preview suggestions
|
|
518
|
+
config = this.mergeConfigs(filteredExistingConfig, newConfig);
|
|
519
|
+
|
|
520
|
+
this.log.debug(
|
|
521
|
+
`Preview config now has ${config.tokowakaOptimizations[urlPath].patches.length} total patches`,
|
|
522
|
+
);
|
|
523
|
+
} else {
|
|
524
|
+
this.log.info(`No deployed patches found for ${urlPath}, using only preview suggestions`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Upload to preview S3 path (replaces any existing preview config)
|
|
529
|
+
this.log.info(`Uploading preview Tokowaka config with ${eligibleSuggestions.length} new suggestions`);
|
|
530
|
+
const s3Path = await this.uploadConfig(apiKey, config, true);
|
|
531
|
+
|
|
532
|
+
// Invalidate CDN cache for preview path
|
|
533
|
+
const cdnInvalidationResult = await this.invalidateCdnCache(
|
|
534
|
+
apiKey,
|
|
535
|
+
this.env.TOKOWAKA_CDN_PROVIDER,
|
|
536
|
+
true,
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
// Fetch HTML content for preview
|
|
540
|
+
let originalHtml = null;
|
|
541
|
+
let optimizedHtml = null;
|
|
542
|
+
|
|
543
|
+
if (previewUrl) {
|
|
544
|
+
try {
|
|
545
|
+
// Fetch original HTML
|
|
546
|
+
originalHtml = await fetchHtmlWithWarmup(
|
|
547
|
+
previewUrl,
|
|
548
|
+
apiKey,
|
|
549
|
+
forwardedHost,
|
|
550
|
+
tokowakaEdgeUrl,
|
|
551
|
+
this.log,
|
|
552
|
+
false,
|
|
553
|
+
options,
|
|
554
|
+
);
|
|
555
|
+
// Then fetch optimized HTML
|
|
556
|
+
optimizedHtml = await fetchHtmlWithWarmup(
|
|
557
|
+
previewUrl,
|
|
558
|
+
apiKey,
|
|
559
|
+
forwardedHost,
|
|
560
|
+
tokowakaEdgeUrl,
|
|
561
|
+
this.log,
|
|
562
|
+
true,
|
|
563
|
+
options,
|
|
564
|
+
);
|
|
565
|
+
this.log.info('Successfully fetched both original and optimized HTML for preview');
|
|
566
|
+
} catch (error) {
|
|
567
|
+
this.log.error(`Failed to fetch HTML for preview: ${error.message}`);
|
|
568
|
+
throw this.#createError(
|
|
569
|
+
`Preview failed: Unable to fetch HTML - ${error.message}`,
|
|
570
|
+
HTTP_INTERNAL_SERVER_ERROR,
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return {
|
|
576
|
+
s3Path,
|
|
577
|
+
config,
|
|
578
|
+
cdnInvalidation: cdnInvalidationResult,
|
|
579
|
+
succeededSuggestions: eligibleSuggestions,
|
|
580
|
+
failedSuggestions: ineligibleSuggestions,
|
|
581
|
+
html: {
|
|
582
|
+
url: previewUrl,
|
|
583
|
+
originalHtml,
|
|
584
|
+
optimizedHtml,
|
|
585
|
+
},
|
|
586
|
+
};
|
|
587
|
+
}
|
|
398
588
|
}
|
|
399
589
|
|
|
400
590
|
// Export the client as default and base classes for custom implementations
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2025 Adobe. All rights reserved.
|
|
3
|
+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
* you may not use this file except in compliance with the License. You may obtain a copy
|
|
5
|
+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
*
|
|
7
|
+
* Unless required by applicable law or agreed to in writing, software distributed under
|
|
8
|
+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
|
9
|
+
* OF ANY KIND, either express or implied. See the License for the specific language
|
|
10
|
+
* governing permissions and limitations under the License.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { hasText } from '@adobe/spacecat-shared-utils';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Helper function to wait for a specified duration
|
|
17
|
+
* @param {number} ms - Milliseconds to wait
|
|
18
|
+
* @returns {Promise<void>}
|
|
19
|
+
*/
|
|
20
|
+
function sleep(ms) {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
setTimeout(resolve, ms);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Makes an HTTP request with retry logic
|
|
28
|
+
* Retries until max retries are exhausted or x-tokowaka-cache header is present
|
|
29
|
+
* @param {string} url - URL to fetch
|
|
30
|
+
* @param {Object} options - Fetch options
|
|
31
|
+
* @param {number} maxRetries - Maximum number of retries
|
|
32
|
+
* @param {number} retryDelayMs - Delay between retries in milliseconds
|
|
33
|
+
* @param {Object} log - Logger instance
|
|
34
|
+
* @param {string} fetchType - Context for logging (e.g., "optimized" or "original")
|
|
35
|
+
* @returns {Promise<Response>} - Fetch response
|
|
36
|
+
*/
|
|
37
|
+
async function fetchWithRetry(url, options, maxRetries, retryDelayMs, log, fetchType) {
|
|
38
|
+
for (let attempt = 1; attempt <= maxRetries + 1; attempt += 1) {
|
|
39
|
+
try {
|
|
40
|
+
log.debug(`Retry attempt ${attempt}/${maxRetries} for ${fetchType} HTML`);
|
|
41
|
+
|
|
42
|
+
// eslint-disable-next-line no-await-in-loop
|
|
43
|
+
const response = await fetch(url, options);
|
|
44
|
+
|
|
45
|
+
log.debug(`Response status (attempt ${attempt}): ${response.status} ${response.statusText}`);
|
|
46
|
+
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Check for x-tokowaka-cache header - if present, stop retrying
|
|
52
|
+
const cacheHeader = response.headers.get('x-tokowaka-cache');
|
|
53
|
+
if (cacheHeader) {
|
|
54
|
+
log.debug(`Cache header found (x-tokowaka-cache: ${cacheHeader}), stopping retry logic`);
|
|
55
|
+
return response;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// If no cache header and we haven't exhausted retries, continue
|
|
59
|
+
if (attempt < maxRetries + 1) {
|
|
60
|
+
log.debug(`No cache header found on attempt ${attempt}, will retry...`);
|
|
61
|
+
// Wait before retrying
|
|
62
|
+
log.debug(`Waiting ${retryDelayMs}ms before retry...`);
|
|
63
|
+
// eslint-disable-next-line no-await-in-loop
|
|
64
|
+
await sleep(retryDelayMs);
|
|
65
|
+
} else {
|
|
66
|
+
// Last attempt without cache header - throw error
|
|
67
|
+
log.error(`Max retries (${maxRetries}) exhausted without cache header`);
|
|
68
|
+
throw new Error(`Cache header (x-tokowaka-cache) not found after ${maxRetries} retries`);
|
|
69
|
+
}
|
|
70
|
+
} catch (error) {
|
|
71
|
+
log.warn(`Attempt ${attempt} failed for ${fetchType} HTML, error: ${error.message}`);
|
|
72
|
+
|
|
73
|
+
// If this was the last attempt, throw the error
|
|
74
|
+
if (attempt === maxRetries + 1) {
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Wait before retrying
|
|
79
|
+
log.debug(`Waiting ${retryDelayMs}ms before retry...`);
|
|
80
|
+
// eslint-disable-next-line no-await-in-loop
|
|
81
|
+
await sleep(retryDelayMs);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
throw new Error(`Failed to fetch ${fetchType} HTML after ${maxRetries} retries`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Fetches HTML content from Tokowaka edge with warmup call and retry logic
|
|
89
|
+
* Makes an initial warmup call, waits, then makes the actual call with retries
|
|
90
|
+
* @param {string} url - Full URL to fetch
|
|
91
|
+
* @param {string} apiKey - Tokowaka API key
|
|
92
|
+
* @param {string} forwardedHost - Host to forward in x-forwarded-host header
|
|
93
|
+
* @param {string} tokowakaEdgeUrl - Tokowaka edge URL
|
|
94
|
+
* @param {boolean} isOptimized - Whether to fetch optimized HTML (with preview param)
|
|
95
|
+
* @param {Object} log - Logger instance
|
|
96
|
+
* @param {Object} options - Additional options
|
|
97
|
+
* @param {number} options.warmupDelayMs - Delay after warmup call (default: 2000ms)
|
|
98
|
+
* @param {number} options.maxRetries - Maximum number of retries for actual call (default: 2)
|
|
99
|
+
* @param {number} options.retryDelayMs - Delay between retries (default: 1000ms)
|
|
100
|
+
* @returns {Promise<string>} - HTML content
|
|
101
|
+
* @throws {Error} - If validation fails or fetch fails after retries
|
|
102
|
+
*/
|
|
103
|
+
export async function fetchHtmlWithWarmup(
|
|
104
|
+
url,
|
|
105
|
+
apiKey,
|
|
106
|
+
forwardedHost,
|
|
107
|
+
tokowakaEdgeUrl,
|
|
108
|
+
log,
|
|
109
|
+
isOptimized = false,
|
|
110
|
+
options = {},
|
|
111
|
+
) {
|
|
112
|
+
// Validate required parameters
|
|
113
|
+
if (!hasText(url)) {
|
|
114
|
+
throw new Error('URL is required for fetching HTML');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!hasText(apiKey)) {
|
|
118
|
+
throw new Error('Tokowaka API key is required for fetching HTML');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!hasText(forwardedHost)) {
|
|
122
|
+
throw new Error('Forwarded host is required for fetching HTML');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!hasText(tokowakaEdgeUrl)) {
|
|
126
|
+
throw new Error('TOKOWAKA_EDGE_URL is not configured');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Default options
|
|
130
|
+
const {
|
|
131
|
+
warmupDelayMs = 2000,
|
|
132
|
+
maxRetries = 3,
|
|
133
|
+
retryDelayMs = 1000,
|
|
134
|
+
} = options;
|
|
135
|
+
|
|
136
|
+
const fetchType = isOptimized ? 'optimized' : 'original';
|
|
137
|
+
|
|
138
|
+
// Parse the URL to extract path and construct full URL
|
|
139
|
+
const urlObj = new URL(url);
|
|
140
|
+
const urlPath = urlObj.pathname + urlObj.search;
|
|
141
|
+
|
|
142
|
+
// Add tokowakaPreview param for optimized HTML
|
|
143
|
+
let fullUrl = `${tokowakaEdgeUrl}${urlPath}`;
|
|
144
|
+
if (isOptimized) {
|
|
145
|
+
const separator = urlPath.includes('?') ? '&' : '?';
|
|
146
|
+
fullUrl = `${fullUrl}${separator}tokowakaPreview=true`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const headers = {
|
|
150
|
+
'x-forwarded-host': forwardedHost,
|
|
151
|
+
'x-tokowaka-api-key': apiKey,
|
|
152
|
+
'x-tokowaka-url': urlPath,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const fetchOptions = {
|
|
156
|
+
method: 'GET',
|
|
157
|
+
headers,
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
// Warmup call (no retry logic for warmup)
|
|
162
|
+
log.debug(`Making warmup call for ${fetchType} HTML with URL: ${fullUrl}`);
|
|
163
|
+
|
|
164
|
+
const warmupResponse = await fetch(fullUrl, fetchOptions);
|
|
165
|
+
|
|
166
|
+
log.debug(`Warmup response status: ${warmupResponse.status} ${warmupResponse.statusText}`);
|
|
167
|
+
// Consume the response body to free up the connection
|
|
168
|
+
await warmupResponse.text();
|
|
169
|
+
log.debug(`Warmup call completed, waiting ${warmupDelayMs}ms...`);
|
|
170
|
+
|
|
171
|
+
// Wait before actual call
|
|
172
|
+
await sleep(warmupDelayMs);
|
|
173
|
+
|
|
174
|
+
// Actual call with retry logic
|
|
175
|
+
log.debug(`Making actual call for ${fetchType} HTML (max ${maxRetries} retries) with URL: ${fullUrl}`);
|
|
176
|
+
|
|
177
|
+
const response = await fetchWithRetry(
|
|
178
|
+
fullUrl,
|
|
179
|
+
fetchOptions,
|
|
180
|
+
maxRetries,
|
|
181
|
+
retryDelayMs,
|
|
182
|
+
log,
|
|
183
|
+
fetchType,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
const html = await response.text();
|
|
187
|
+
log.debug(`Successfully fetched ${fetchType} HTML (${html.length} bytes)`);
|
|
188
|
+
return html;
|
|
189
|
+
} catch (error) {
|
|
190
|
+
const errorMsg = `Failed to fetch ${fetchType} HTML after ${maxRetries} retries: ${error.message}`;
|
|
191
|
+
log.error(errorMsg);
|
|
192
|
+
throw new Error(errorMsg);
|
|
193
|
+
}
|
|
194
|
+
}
|
package/src/utils/s3-utils.js
CHANGED
|
@@ -13,8 +13,12 @@
|
|
|
13
13
|
/**
|
|
14
14
|
* Generates S3 path for Tokowaka configuration
|
|
15
15
|
* @param {string} apiKey - Tokowaka API key
|
|
16
|
+
* @param {boolean} isPreview - Whether this is a preview path
|
|
16
17
|
* @returns {string} - S3 path
|
|
17
18
|
*/
|
|
18
|
-
export function getTokowakaConfigS3Path(apiKey) {
|
|
19
|
+
export function getTokowakaConfigS3Path(apiKey, isPreview = false) {
|
|
20
|
+
if (isPreview) {
|
|
21
|
+
return `preview/opportunities/${apiKey}`;
|
|
22
|
+
}
|
|
19
23
|
return `opportunities/${apiKey}`;
|
|
20
24
|
}
|