@adobe/spacecat-shared-tokowaka-client 1.1.1 → 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.
@@ -0,0 +1,195 @@
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
+ /* c8 ignore next */
85
+ throw new Error(`Failed to fetch ${fetchType} HTML after ${maxRetries} retries`);
86
+ }
87
+
88
+ /**
89
+ * Fetches HTML content from Tokowaka edge with warmup call and retry logic
90
+ * Makes an initial warmup call, waits, then makes the actual call with retries
91
+ * @param {string} url - Full URL to fetch
92
+ * @param {string} apiKey - Tokowaka API key
93
+ * @param {string} forwardedHost - Host to forward in x-forwarded-host header
94
+ * @param {string} tokowakaEdgeUrl - Tokowaka edge URL
95
+ * @param {boolean} isOptimized - Whether to fetch optimized HTML (with preview param)
96
+ * @param {Object} log - Logger instance
97
+ * @param {Object} options - Additional options
98
+ * @param {number} options.warmupDelayMs - Delay after warmup call (default: 2000ms)
99
+ * @param {number} options.maxRetries - Maximum number of retries for actual call (default: 2)
100
+ * @param {number} options.retryDelayMs - Delay between retries (default: 1000ms)
101
+ * @returns {Promise<string>} - HTML content
102
+ * @throws {Error} - If validation fails or fetch fails after retries
103
+ */
104
+ export async function fetchHtmlWithWarmup(
105
+ url,
106
+ apiKey,
107
+ forwardedHost,
108
+ tokowakaEdgeUrl,
109
+ log,
110
+ isOptimized = false,
111
+ options = {},
112
+ ) {
113
+ // Validate required parameters
114
+ if (!hasText(url)) {
115
+ throw new Error('URL is required for fetching HTML');
116
+ }
117
+
118
+ if (!hasText(apiKey)) {
119
+ throw new Error('Tokowaka API key is required for fetching HTML');
120
+ }
121
+
122
+ if (!hasText(forwardedHost)) {
123
+ throw new Error('Forwarded host is required for fetching HTML');
124
+ }
125
+
126
+ if (!hasText(tokowakaEdgeUrl)) {
127
+ throw new Error('TOKOWAKA_EDGE_URL is not configured');
128
+ }
129
+
130
+ // Default options
131
+ const {
132
+ warmupDelayMs = 2000,
133
+ maxRetries = 3,
134
+ retryDelayMs = 1000,
135
+ } = options;
136
+
137
+ const fetchType = isOptimized ? 'optimized' : 'original';
138
+
139
+ // Parse the URL to extract path and construct full URL
140
+ const urlObj = new URL(url);
141
+ const urlPath = urlObj.pathname + urlObj.search;
142
+
143
+ // Add tokowakaPreview param for optimized HTML
144
+ let fullUrl = `${tokowakaEdgeUrl}${urlPath}`;
145
+ if (isOptimized) {
146
+ const separator = urlPath.includes('?') ? '&' : '?';
147
+ fullUrl = `${fullUrl}${separator}tokowakaPreview=true`;
148
+ }
149
+
150
+ const headers = {
151
+ 'x-forwarded-host': forwardedHost,
152
+ 'x-tokowaka-api-key': apiKey,
153
+ 'x-tokowaka-url': urlPath,
154
+ };
155
+
156
+ const fetchOptions = {
157
+ method: 'GET',
158
+ headers,
159
+ };
160
+
161
+ try {
162
+ // Warmup call (no retry logic for warmup)
163
+ log.debug(`Making warmup call for ${fetchType} HTML with URL: ${fullUrl}`);
164
+
165
+ const warmupResponse = await fetch(fullUrl, fetchOptions);
166
+
167
+ log.debug(`Warmup response status: ${warmupResponse.status} ${warmupResponse.statusText}`);
168
+ // Consume the response body to free up the connection
169
+ await warmupResponse.text();
170
+ log.debug(`Warmup call completed, waiting ${warmupDelayMs}ms...`);
171
+
172
+ // Wait before actual call
173
+ await sleep(warmupDelayMs);
174
+
175
+ // Actual call with retry logic
176
+ log.debug(`Making actual call for ${fetchType} HTML (max ${maxRetries} retries) with URL: ${fullUrl}`);
177
+
178
+ const response = await fetchWithRetry(
179
+ fullUrl,
180
+ fetchOptions,
181
+ maxRetries,
182
+ retryDelayMs,
183
+ log,
184
+ fetchType,
185
+ );
186
+
187
+ const html = await response.text();
188
+ log.debug(`Successfully fetched ${fetchType} HTML (${html.length} bytes)`);
189
+ return html;
190
+ } catch (error) {
191
+ const errorMsg = `Failed to fetch ${fetchType} HTML after ${maxRetries} retries: ${error.message}`;
192
+ log.error(errorMsg);
193
+ throw new Error(errorMsg);
194
+ }
195
+ }
@@ -0,0 +1,24 @@
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 { toHast } from 'mdast-util-to-hast';
14
+ import { fromMarkdown } from 'mdast-util-from-markdown';
15
+
16
+ /**
17
+ * Converts markdown text to HAST (Hypertext Abstract Syntax Tree) format
18
+ * @param {string} markdown - Markdown text
19
+ * @returns {Object} - HAST object
20
+ */
21
+ export function markdownToHast(markdown) {
22
+ const mdast = fromMarkdown(markdown);
23
+ return toHast(mdast);
24
+ }
@@ -0,0 +1,103 @@
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
+ /**
14
+ * Generates a unique key for a patch based on its structure
15
+ * Individual patches (one suggestion per patch):
16
+ * → Key: opportunityId:suggestionId
17
+ * Patches with no suggestionId:
18
+ * → Key: opportunityId
19
+ */
20
+ export function getPatchKey(patch) {
21
+ // Heading patch (no suggestionId): use special key
22
+ if (!patch.suggestionId) {
23
+ return `${patch.opportunityId}`;
24
+ }
25
+
26
+ // Individual patches include suggestionId in key
27
+ // This ensures each suggestion gets its own separate patch
28
+ return `${patch.opportunityId}:${patch.suggestionId}`;
29
+ }
30
+
31
+ /**
32
+ * Merges new patches into existing patches based on patch keys
33
+ * - If a patch with the same key exists, it's updated
34
+ * - If a patch with a new key is found, it's added
35
+ * @param {Array} existingPatches - Array of existing patches
36
+ * @param {Array} newPatches - Array of new patches to merge
37
+ * @returns {Object} - { patches: Array, updateCount: number, addCount: number }
38
+ */
39
+ export function mergePatches(existingPatches, newPatches) {
40
+ // Create a map of existing patches by their key
41
+ const patchMap = new Map();
42
+ existingPatches.forEach((patch, index) => {
43
+ const key = getPatchKey(patch);
44
+ patchMap.set(key, { patch, index });
45
+ });
46
+
47
+ // Process new patches
48
+ const mergedPatches = [...existingPatches];
49
+ let updateCount = 0;
50
+ let addCount = 0;
51
+
52
+ newPatches.forEach((newPatch) => {
53
+ const key = getPatchKey(newPatch);
54
+ const existing = patchMap.get(key);
55
+
56
+ if (existing) {
57
+ mergedPatches[existing.index] = newPatch;
58
+ updateCount += 1;
59
+ } else {
60
+ mergedPatches.push(newPatch);
61
+ addCount += 1;
62
+ }
63
+ });
64
+
65
+ return { patches: mergedPatches, updateCount, addCount };
66
+ }
67
+
68
+ /**
69
+ * Removes patches matching the given suggestion IDs from a config
70
+ * Works with flat config structure
71
+ * @param {Object} config - Tokowaka configuration object
72
+ * @param {Array<string>} suggestionIds - Array of suggestion IDs to remove
73
+ * @param {Array<string>} additionalPatchKeys - Optional array of additional patch keys to remove
74
+ * @returns {Object} - Updated configuration with patches removed
75
+ */
76
+ export function removePatchesBySuggestionIds(config, suggestionIds, additionalPatchKeys = []) {
77
+ if (!config || !config.patches) {
78
+ return config;
79
+ }
80
+
81
+ const suggestionIdSet = new Set(suggestionIds);
82
+ const patchKeysToRemove = new Set(additionalPatchKeys);
83
+ let removedCount = 0;
84
+
85
+ // Filter out patches with matching suggestionIds or additional patch keys
86
+ const filteredPatches = config.patches.filter((patch) => {
87
+ const shouldRemoveBySuggestionId = suggestionIdSet.has(patch.suggestionId);
88
+ const patchKey = getPatchKey(patch);
89
+ const shouldRemoveByPatchKey = patchKeysToRemove.has(patchKey);
90
+
91
+ if (shouldRemoveBySuggestionId || shouldRemoveByPatchKey) {
92
+ removedCount += 1;
93
+ return false;
94
+ }
95
+ return true;
96
+ });
97
+
98
+ return {
99
+ ...config,
100
+ patches: filteredPatches,
101
+ removedCount,
102
+ };
103
+ }
@@ -0,0 +1,117 @@
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
+ /**
14
+ * Normalizes a URL pathname for S3 storage
15
+ * - Removes trailing slash (except for root '/')
16
+ * - Ensures starts with '/'
17
+ * @param {string} pathname - URL pathname
18
+ * @returns {string} - Normalized pathname
19
+ */
20
+ export function normalizePath(pathname) {
21
+ let normalized = pathname.endsWith('/') && pathname !== '/' ? pathname.slice(0, -1) : pathname;
22
+
23
+ if (!normalized.startsWith('/')) {
24
+ normalized = `/${normalized}`;
25
+ }
26
+
27
+ return normalized;
28
+ }
29
+
30
+ /**
31
+ * Extracts and normalizes hostname from URL
32
+ * - Strips 'www.' prefix
33
+ * @param {URL} url - URL object
34
+ * @param {Object} logger - Logger instance
35
+ * @returns {string} - Normalized hostname
36
+ * @throws {Error} - If hostname extraction fails
37
+ */
38
+ export function getHostName(url, logger) {
39
+ try {
40
+ const finalHostname = url.hostname.replace(/^www\./, '');
41
+ return finalHostname;
42
+ } catch (error) {
43
+ logger.error(`Error extracting host name: ${error.message}`);
44
+ throw new Error(`Error extracting host name: ${url.toString()}`);
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Base64 URL encodes a string (RFC 4648)
50
+ * - Uses URL-safe characters (- instead of +, _ instead of /)
51
+ * - Removes padding (=)
52
+ * @param {string} input - String to encode
53
+ * @returns {string} - Base64 URL encoded string
54
+ */
55
+ export function base64UrlEncode(input) {
56
+ // Encode to UTF-8 bytes
57
+ const bytes = new TextEncoder().encode(input);
58
+ // Convert bytes → binary string
59
+ let binary = '';
60
+ for (let i = 0; i < bytes.length; i += 1) {
61
+ binary += String.fromCharCode(bytes[i]);
62
+ }
63
+ // Standard base64
64
+ const base64 = btoa(binary);
65
+ // Convert to base64url (RFC 4648)
66
+ return base64
67
+ .replace(/\+/g, '-') // + → -
68
+ .replace(/\//g, '_') // / → _
69
+ .replace(/=+$/, ''); // remove padding
70
+ }
71
+
72
+ /**
73
+ * Generates S3 path for Tokowaka configuration based on URL
74
+ * @param {string} url - Full URL (e.g., 'https://www.example.com/products/item')
75
+ * @param {Object} logger - Logger instance
76
+ * @param {boolean} isPreview - Whether this is a preview path
77
+ * @returns {string} - S3 path (e.g., 'opportunities/example.com/L3Byb2R1Y3RzL2l0ZW0')
78
+ * @throws {Error} - If URL parsing fails
79
+ */
80
+ export function getTokowakaConfigS3Path(url, logger, isPreview = false) {
81
+ try {
82
+ const urlObj = new URL(url);
83
+ let path = urlObj.pathname;
84
+
85
+ path = normalizePath(path);
86
+ path = base64UrlEncode(path);
87
+
88
+ const normalizedHostName = getHostName(urlObj, logger);
89
+ const prefix = isPreview ? 'preview/opportunities' : 'opportunities';
90
+
91
+ return `${prefix}/${normalizedHostName}/${path}`;
92
+ } catch (error) {
93
+ logger.error(`Error generating S3 path for URL ${url}: ${error.message}`);
94
+ throw new Error(`Failed to generate S3 path: ${error.message}`);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Generates S3 path for domain-level metaconfig
100
+ * @param {string} url - Full URL (used to extract domain)
101
+ * @param {Object} logger - Logger instance
102
+ * @param {boolean} isPreview - Whether this is a preview path
103
+ * @returns {string} - S3 path for metaconfig (e.g., 'opportunities/example.com/config')
104
+ * @throws {Error} - If URL parsing fails
105
+ */
106
+ export function getTokowakaMetaconfigS3Path(url, logger, isPreview = false) {
107
+ try {
108
+ const urlObj = new URL(url);
109
+ const normalizedHostName = getHostName(urlObj, logger);
110
+ const prefix = isPreview ? 'preview/opportunities' : 'opportunities';
111
+
112
+ return `${prefix}/${normalizedHostName}/config`;
113
+ } catch (error) {
114
+ logger.error(`Error generating metaconfig S3 path for URL ${url}: ${error.message}`);
115
+ throw new Error(`Failed to generate metaconfig S3 path: ${error.message}`);
116
+ }
117
+ }
@@ -0,0 +1,25 @@
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 { isValidUrl } from '@adobe/spacecat-shared-utils';
14
+
15
+ /**
16
+ * Gets the effective base URL for a site, respecting overrideBaseURL if configured
17
+ * @param {Object} site - Site entity
18
+ * @returns {string} - Base URL to use
19
+ */
20
+ export function getEffectiveBaseURL(site) {
21
+ const overrideBaseURL = site.getConfig()?.getFetchConfig?.()?.overrideBaseURL;
22
+ return (overrideBaseURL && isValidUrl(overrideBaseURL))
23
+ ? overrideBaseURL
24
+ : site.getBaseURL();
25
+ }
@@ -0,0 +1,69 @@
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
+ /**
14
+ * Groups suggestions by URL pathname
15
+ * @param {Array} suggestions - Array of suggestion entities
16
+ * @param {string} baseURL - Base URL for pathname extraction
17
+ * @param {Object} log - Logger instance
18
+ * @returns {Object} - Object with URL paths as keys and arrays of suggestions as values
19
+ */
20
+ export function groupSuggestionsByUrlPath(suggestions, baseURL, log) {
21
+ return suggestions.reduce((acc, suggestion) => {
22
+ const data = suggestion.getData();
23
+ const url = data?.url;
24
+
25
+ if (!url) {
26
+ log.warn(`Suggestion ${suggestion.getId()} does not have a URL, skipping`);
27
+ return acc;
28
+ }
29
+
30
+ let urlPath;
31
+ try {
32
+ urlPath = new URL(url, baseURL).pathname;
33
+ } catch (e) {
34
+ log.warn(`Failed to extract pathname from URL for suggestion ${suggestion.getId()}: ${url}`);
35
+ return acc;
36
+ }
37
+
38
+ if (!acc[urlPath]) {
39
+ acc[urlPath] = [];
40
+ }
41
+ acc[urlPath].push(suggestion);
42
+ return acc;
43
+ }, {});
44
+ }
45
+
46
+ /**
47
+ * Filters suggestions into eligible and ineligible based on mapper's canDeploy method
48
+ * @param {Array} suggestions - Array of suggestion entities
49
+ * @param {Object} mapper - Mapper instance with canDeploy method
50
+ * @returns {Object} - { eligible: Array, ineligible: Array<{suggestion, reason}> }
51
+ */
52
+ export function filterEligibleSuggestions(suggestions, mapper) {
53
+ const eligible = [];
54
+ const ineligible = [];
55
+
56
+ suggestions.forEach((suggestion) => {
57
+ const eligibility = mapper.canDeploy(suggestion);
58
+ if (eligibility.eligible) {
59
+ eligible.push(suggestion);
60
+ } else {
61
+ ineligible.push({
62
+ suggestion,
63
+ reason: eligibility.reason || 'Suggestion cannot be deployed',
64
+ });
65
+ }
66
+ });
67
+
68
+ return { eligible, ineligible };
69
+ }