@dylandepass/helix-product-pipeline 2.0.2 → 2.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 +20 -0
- package/README.md +8 -6
- package/package.json +2 -2
- package/src/global.d.ts +2 -1
- package/src/index.js +1 -1
- package/src/product-html-pipe.js +4 -1
- package/src/steps/extract-authored-metadata.js +120 -0
- package/src/steps/fetch-edge-product.js +1 -1
- package/src/steps/render-body.js +46 -15
- package/src/steps/set-cache-headers.js +12 -92
- package/src/utils/cache-headers.js +76 -0
- package/src/product-media-pipe.js +0 -64
- package/src/steps/fetch-media.js +0 -41
- package/src/steps/fetch-productbus-old.js +0 -91
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
# [2.2.0](https://github.com/adobe-rnd/helix-product-pipeline/compare/v2.1.0...v2.2.0) (2026-02-04)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* Remove media pipe and move to helix-product-pipeline-worker ([645d2f5](https://github.com/adobe-rnd/helix-product-pipeline/commit/645d2f5850f0b4f5abfe25e6330c68e31508d812))
|
|
7
|
+
|
|
8
|
+
# [2.1.0](https://github.com/adobe-rnd/helix-product-pipeline/compare/v2.0.2...v2.1.0) (2026-02-03)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* support metadata from edge ([8a9c032](https://github.com/adobe-rnd/helix-product-pipeline/commit/8a9c032f2b5e55449a4677e4ce6f4adb62d9c9a2))
|
|
14
|
+
* update tests ([f0cb051](https://github.com/adobe-rnd/helix-product-pipeline/commit/f0cb051da917b2b5c3d56e460f2c9b9284da9a0c))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
* extract metadata from authored edge content and optimize cache keys ([0be7f2b](https://github.com/adobe-rnd/helix-product-pipeline/commit/0be7f2b90424216f3a29d8fd536ac19407281947))
|
|
20
|
+
|
|
1
21
|
## [2.0.2](https://github.com/adobe-rnd/helix-product-pipeline/compare/v2.0.1...v2.0.2) (2026-01-29)
|
|
2
22
|
|
|
3
23
|
|
package/README.md
CHANGED
|
@@ -34,6 +34,7 @@ Helix Product Pipeline is the shared rendering engine that powers both `helix-pr
|
|
|
34
34
|
|
|
35
35
|
- Fetches product data from Product Bus storage
|
|
36
36
|
- Optionally merges with authored content from Edge Delivery Services
|
|
37
|
+
- Extracts metadata from authored content head and merges into product head
|
|
37
38
|
- Generates responsive picture elements with multiple breakpoints (600px, 750px/2000px)
|
|
38
39
|
- Creates schema.org JSON-LD structured data for rich search results
|
|
39
40
|
- Adds heading IDs for navigation and anchor links
|
|
@@ -50,12 +51,13 @@ The HTML pipeline executes these processing steps in sequence:
|
|
|
50
51
|
4. **make-html** - Create initial HTML structure
|
|
51
52
|
5. **render-head** - Generate HTML head with meta tags
|
|
52
53
|
6. **fetch-edge-product** - Optionally fetch authored content from Edge Delivery
|
|
53
|
-
7. **
|
|
54
|
-
8. **render-
|
|
55
|
-
9. **
|
|
56
|
-
10. **
|
|
57
|
-
11. **
|
|
58
|
-
12. **
|
|
54
|
+
7. **extract-authored-metadata** - Extract metadata from authored content and merge into product head
|
|
55
|
+
8. **render-body** - Render product content, images, and variants
|
|
56
|
+
9. **render-jsonld** - Generate schema.org JSON-LD structured data
|
|
57
|
+
10. **add-heading-ids** - Add IDs to headings for anchor navigation
|
|
58
|
+
11. **create-pictures** - Generate responsive picture elements
|
|
59
|
+
12. **stringify-response** - Serialize to HTML string
|
|
60
|
+
13. **set-cache-headers** - Set CDN and browser cache headers with surrogate keys
|
|
59
61
|
|
|
60
62
|
### CDN Support
|
|
61
63
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dylandepass/helix-product-pipeline",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Helix Product Pipeline",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "src/index.d.ts",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@adobe/helix-html-pipeline": "6.27.9",
|
|
49
49
|
"@adobe/helix-shared-utils": "3.0.2",
|
|
50
|
-
"@dylandepass/helix-product-shared": "1.
|
|
50
|
+
"@dylandepass/helix-product-shared": "1.5.2",
|
|
51
51
|
"dayjs": "1.11.19",
|
|
52
52
|
"github-slugger": "2.0.0",
|
|
53
53
|
"hast-util-from-html": "^2.0.3",
|
package/src/global.d.ts
CHANGED
package/src/index.js
CHANGED
|
@@ -15,4 +15,4 @@ export { productHTMLPipe } from './product-html-pipe.js';
|
|
|
15
15
|
export { productIndexPipe } from './product-index-pipe.js';
|
|
16
16
|
export { productMerchantFeedPipe } from './product-merchant-feed-pipe.js';
|
|
17
17
|
export { productSitemapPipe } from './product-sitemap-pipe.js';
|
|
18
|
-
export {
|
|
18
|
+
export { applyByoCdnHeaders } from './utils/cache-headers.js';
|
package/src/product-html-pipe.js
CHANGED
|
@@ -26,6 +26,7 @@ import tohtml from './steps/stringify-response.js';
|
|
|
26
26
|
import fetch404 from './steps/fetch-404.js';
|
|
27
27
|
import { setProductCacheHeaders } from './steps/set-cache-headers.js';
|
|
28
28
|
import { getUnauthorizedBody } from './utils/http-response.js';
|
|
29
|
+
import extractAuthoredMetadata from './steps/extract-authored-metadata.js';
|
|
29
30
|
|
|
30
31
|
export async function productHTMLPipe(state, req) {
|
|
31
32
|
const { log } = state;
|
|
@@ -72,9 +73,11 @@ export async function productHTMLPipe(state, req) {
|
|
|
72
73
|
|
|
73
74
|
state.timer?.update('render');
|
|
74
75
|
await html(state);
|
|
75
|
-
await renderHead(state);
|
|
76
76
|
|
|
77
77
|
await fetchEdgeContent(state, req, res);
|
|
78
|
+
await extractAuthoredMetadata(state);
|
|
79
|
+
|
|
80
|
+
await renderHead(state);
|
|
78
81
|
await renderBody(state, req, res);
|
|
79
82
|
await renderJsonld(state, res);
|
|
80
83
|
await addHeadingIds(state);
|
|
@@ -0,0 +1,120 @@
|
|
|
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 { selectAll, select } from 'hast-util-select';
|
|
14
|
+
import { fromHtml } from 'hast-util-from-html';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Set of meta tag names to ignore when extracting authored metadata.
|
|
18
|
+
* These are standard meta tags that we don't want to override.
|
|
19
|
+
*/
|
|
20
|
+
const IGNORED_META_NAMES = new Set([
|
|
21
|
+
'viewport',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Prefixes for meta tag names to ignore.
|
|
26
|
+
* og: and twitter: prefixes are standard social meta tags.
|
|
27
|
+
*/
|
|
28
|
+
const IGNORED_META_PREFIXES = ['og:', 'twitter:'];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check if a meta tag name should be ignored.
|
|
32
|
+
* @param {string} name - The meta tag name
|
|
33
|
+
* @returns {boolean} True if the name should be ignored
|
|
34
|
+
*/
|
|
35
|
+
function shouldIgnoreMetaName(name) {
|
|
36
|
+
if (!name) return true;
|
|
37
|
+
if (IGNORED_META_NAMES.has(name)) return true;
|
|
38
|
+
return IGNORED_META_PREFIXES.some((prefix) => name.startsWith(prefix));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Extract metadata from the head element of an HTML document.
|
|
43
|
+
* Only extracts meta tags with name attribute (not property).
|
|
44
|
+
* Ignores og:*, twitter:*, and viewport meta tags.
|
|
45
|
+
*
|
|
46
|
+
* @param {import('hast').Element} head - The head element
|
|
47
|
+
* @returns {Object} Extracted metadata key/value pairs
|
|
48
|
+
*/
|
|
49
|
+
function extractMetadataFromHead(head) {
|
|
50
|
+
const metadata = {};
|
|
51
|
+
|
|
52
|
+
/* c8 ignore next */
|
|
53
|
+
if (!head) return metadata;
|
|
54
|
+
|
|
55
|
+
// Select all meta tags with a name attribute
|
|
56
|
+
const metaTags = selectAll('meta[name]', head);
|
|
57
|
+
|
|
58
|
+
for (const meta of metaTags) {
|
|
59
|
+
const name = meta.properties?.name;
|
|
60
|
+
const content = meta.properties?.content;
|
|
61
|
+
|
|
62
|
+
// Only add if name is valid and content exists
|
|
63
|
+
if (typeof name === 'string' && !shouldIgnoreMetaName(name) && content !== undefined) {
|
|
64
|
+
metadata[name] = content;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return metadata;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Extract the main element's children as HAST nodes.
|
|
73
|
+
* @param {import('hast').Root} hast - The parsed HTML document
|
|
74
|
+
* @returns {import('hast').Root|undefined} HAST root with main's children,
|
|
75
|
+
* or undefined if not found
|
|
76
|
+
*/
|
|
77
|
+
function extractMainContent(hast) {
|
|
78
|
+
const main = select('main', hast);
|
|
79
|
+
if (!main || !main.children || main.children.length === 0) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Return main's children as a HAST root node
|
|
84
|
+
return { type: 'root', children: main.children };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Extract metadata and body content from authored HTML document.
|
|
89
|
+
*
|
|
90
|
+
* @param {PipelineState} state - Pipeline state object
|
|
91
|
+
* @returns {Promise<void>}
|
|
92
|
+
*/
|
|
93
|
+
export default async function extractAuthoredMetadata(state) {
|
|
94
|
+
const { content } = state;
|
|
95
|
+
const { edge, data } = content;
|
|
96
|
+
|
|
97
|
+
if (!edge) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const hast = fromHtml(edge, { fragment: false });
|
|
102
|
+
const head = select('head', hast);
|
|
103
|
+
const authoredMetadata = extractMetadataFromHead(head);
|
|
104
|
+
|
|
105
|
+
// If metadata was extracted, merge it with existing product metadata
|
|
106
|
+
if (Object.keys(authoredMetadata).length > 0) {
|
|
107
|
+
if (!data.metadata) {
|
|
108
|
+
data.metadata = {};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Authored metadata takes precedence
|
|
112
|
+
Object.assign(data.metadata, authoredMetadata);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Extract main content as HAST and store for render-body to use directly
|
|
116
|
+
const mainContent = extractMainContent(hast);
|
|
117
|
+
if (mainContent) {
|
|
118
|
+
content.edgeHast = mainContent;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -26,7 +26,7 @@ export default async function fetchEdgeContent(state, req, res) {
|
|
|
26
26
|
} = state;
|
|
27
27
|
const { originalPath } = info;
|
|
28
28
|
|
|
29
|
-
const contentUrl = `https://${ref}--${site}--${org}.aem.live${originalPath}
|
|
29
|
+
const contentUrl = `https://${ref}--${site}--${org}.aem.live${originalPath}`;
|
|
30
30
|
try {
|
|
31
31
|
/** @type {Record<string, string>} */
|
|
32
32
|
const headers = {};
|
package/src/steps/render-body.js
CHANGED
|
@@ -45,22 +45,54 @@ function renderMedia(media) {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
* Rewrite
|
|
48
|
+
* Rewrite a single URL to include /content-images/ prefix for media files.
|
|
49
|
+
* @param {string} url - The URL to potentially rewrite
|
|
50
|
+
* @returns {string} The rewritten URL
|
|
51
|
+
*/
|
|
52
|
+
function rewriteMediaUrl(url) {
|
|
53
|
+
/* c8 ignore next */
|
|
54
|
+
if (!url) return url;
|
|
55
|
+
return url.replace(/\/(media_[a-f0-9]+\.\w+)/g, '/content-images/$1');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Recursively rewrite image URLs in HAST nodes to include /content-images/ prefix.
|
|
49
60
|
* This allows the aem.network to route these images back to aem.live instead of the product media bus.
|
|
50
|
-
* @param {
|
|
51
|
-
* @returns {string} HTML with rewritten image URLs
|
|
61
|
+
* @param {import('hast').Root|import('hast').Element} node - HAST node to process
|
|
52
62
|
*/
|
|
53
|
-
export function rewriteContentImageUrls(
|
|
54
|
-
|
|
63
|
+
export function rewriteContentImageUrls(node) {
|
|
64
|
+
/* c8 ignore next */
|
|
65
|
+
if (!node) return;
|
|
66
|
+
|
|
67
|
+
// Rewrite src and srcset attributes on img and source elements
|
|
68
|
+
if (node.type === 'element' && node.properties) {
|
|
69
|
+
if (node.tagName === 'img' || node.tagName === 'source') {
|
|
70
|
+
if (node.properties.src && typeof node.properties.src === 'string') {
|
|
71
|
+
node.properties.src = rewriteMediaUrl(node.properties.src);
|
|
72
|
+
}
|
|
73
|
+
if (node.properties.srcSet && typeof node.properties.srcSet === 'string') {
|
|
74
|
+
node.properties.srcSet = rewriteMediaUrl(node.properties.srcSet);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Recursively process children
|
|
80
|
+
if (node.children) {
|
|
81
|
+
for (const child of node.children) {
|
|
82
|
+
if (child.type === 'element') {
|
|
83
|
+
rewriteContentImageUrls(child);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
55
87
|
}
|
|
56
88
|
|
|
57
89
|
/**
|
|
58
90
|
* Render the product content.
|
|
59
|
-
* @param {
|
|
60
|
-
* @param {string} description
|
|
91
|
+
* @param {import('hast').Root} edgeHast - HAST nodes from authored content
|
|
92
|
+
* @param {string} description - Product description (may be HTML or plain text)
|
|
61
93
|
* @returns {import('hast').Element | import('hast').Root}
|
|
62
94
|
*/
|
|
63
|
-
function renderProductContent(
|
|
95
|
+
function renderProductContent(edgeHast, description) {
|
|
64
96
|
const parts = [];
|
|
65
97
|
|
|
66
98
|
// Add description first if it exists
|
|
@@ -74,11 +106,10 @@ function renderProductContent(edge, description) {
|
|
|
74
106
|
}
|
|
75
107
|
}
|
|
76
108
|
|
|
77
|
-
// Add edge content after if it exists
|
|
78
|
-
if (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
parts.push(...edgeFragment.children);
|
|
109
|
+
// Add edge content after if it exists (already HAST, just rewrite URLs)
|
|
110
|
+
if (edgeHast) {
|
|
111
|
+
rewriteContentImageUrls(edgeHast);
|
|
112
|
+
parts.push(...edgeHast.children);
|
|
82
113
|
}
|
|
83
114
|
|
|
84
115
|
// Return undefined if nothing to render
|
|
@@ -110,7 +141,7 @@ function variantDataAttrs(variant) {
|
|
|
110
141
|
*/
|
|
111
142
|
export default async function render(state, req, res) {
|
|
112
143
|
const { content } = state;
|
|
113
|
-
const { hast,
|
|
144
|
+
const { hast, edgeHast } = content;
|
|
114
145
|
|
|
115
146
|
const {
|
|
116
147
|
name,
|
|
@@ -120,7 +151,7 @@ export default async function render(state, req, res) {
|
|
|
120
151
|
variants = [],
|
|
121
152
|
} = content.data;
|
|
122
153
|
|
|
123
|
-
const productContent = renderProductContent(
|
|
154
|
+
const productContent = renderProductContent(edgeHast, description);
|
|
124
155
|
|
|
125
156
|
const main = select('main', hast);
|
|
126
157
|
main.children = [
|
|
@@ -13,11 +13,13 @@
|
|
|
13
13
|
/* eslint-disable camelcase */
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
|
-
computeProductPathKey,
|
|
16
|
+
computeProductPathKey,
|
|
17
|
+
computeSiteKey,
|
|
18
|
+
compute404Key,
|
|
19
|
+
computeAuthoredContentKey,
|
|
20
|
+
computeProductKeys,
|
|
17
21
|
} from '@dylandepass/helix-product-shared';
|
|
18
|
-
|
|
19
|
-
export const isMediaRequest = (url) => /\/media_[0-9a-f]{40,}[/a-zA-Z0-9_-]*\.[0-9a-z]+$/.test(url.pathname);
|
|
20
|
-
const BYO_CDN_TYPES = ['akamai', 'cloudflare', 'fastly', 'cloudfront'];
|
|
22
|
+
import { applyByoCdnHeaders } from '../utils/cache-headers.js';
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
25
|
* Sets the cache headers for a product
|
|
@@ -27,92 +29,29 @@ const BYO_CDN_TYPES = ['akamai', 'cloudflare', 'fastly', 'cloudfront'];
|
|
|
27
29
|
* @returns {void}
|
|
28
30
|
*/
|
|
29
31
|
export function setCachingHeaders(req, resp, cacheKeys) {
|
|
30
|
-
const url = new URL(req.url);
|
|
31
|
-
|
|
32
|
-
// determine BYO CDN type
|
|
33
|
-
let byo_cdn_type = req.headers.get('x-byo-cdn-type');
|
|
34
|
-
const via = req.headers.get('via') || '';
|
|
35
|
-
const cdn_loop = req.headers.get('cdn-loop') || '';
|
|
36
|
-
// FIXME: cloudflare seems to strip the CDN-Loop header, i.e. the worker can't see it...
|
|
37
|
-
if (!BYO_CDN_TYPES.includes(byo_cdn_type)) {
|
|
38
|
-
// sniff downstream cdn type
|
|
39
|
-
if (/[Aa]kamai/.test(via)) {
|
|
40
|
-
byo_cdn_type = 'akamai';
|
|
41
|
-
} else if (via.includes('varnish') || cdn_loop.startsWith('Fastly')) {
|
|
42
|
-
byo_cdn_type = 'fastly';
|
|
43
|
-
} else if (cdn_loop.includes('cloudflare') || req.headers.has('cf-worker')) {
|
|
44
|
-
byo_cdn_type = 'cloudflare';
|
|
45
|
-
} else if (via.includes('CloudFront')) {
|
|
46
|
-
byo_cdn_type = 'cloudfront';
|
|
47
|
-
} else {
|
|
48
|
-
// invalid/unsupported CDN type
|
|
49
|
-
byo_cdn_type = undefined;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
32
|
// cache instructions for BYO CDN and the browser
|
|
54
33
|
const pushInvalidation = req.headers.get('x-push-invalidation') === 'enabled';
|
|
34
|
+
|
|
55
35
|
// browser ttl & browser cache-control
|
|
56
36
|
let browserTTL;
|
|
57
37
|
if ([302, 400, 401].includes(resp.status)) {
|
|
58
38
|
browserTTL = 0;
|
|
59
39
|
resp.headers.set('cache-control', 'private; no-cache');
|
|
60
40
|
} else {
|
|
61
|
-
|
|
62
|
-
browserTTL = resp.status === 200 ? 2592000 : 3600;
|
|
63
|
-
} else {
|
|
64
|
-
browserTTL = 7200;
|
|
65
|
-
}
|
|
41
|
+
browserTTL = 7200;
|
|
66
42
|
resp.headers.set('cache-control', `max-age=${browserTTL}, must-revalidate`);
|
|
67
43
|
}
|
|
68
44
|
|
|
69
45
|
// CDN ttl
|
|
70
46
|
let cdnTTL;
|
|
71
47
|
if ([400, 401].includes(resp.status)) {
|
|
72
|
-
// don't cache 400 & 401
|
|
48
|
+
// don't cache 400 & 401 responses in order to avoid potential downstream caching issues
|
|
73
49
|
cdnTTL = 0;
|
|
74
|
-
} else if (isMediaRequest(url)) {
|
|
75
|
-
cdnTTL = resp.status === 200 ? 2592000 : 3600;
|
|
76
50
|
} else {
|
|
77
51
|
cdnTTL = pushInvalidation ? 172800 : 300;
|
|
78
52
|
}
|
|
79
|
-
switch (byo_cdn_type) {
|
|
80
|
-
case 'cloudflare':
|
|
81
|
-
resp.headers.set('cdn-cache-control', `max-age=${cdnTTL}, must-revalidate`);
|
|
82
|
-
break;
|
|
83
|
-
case 'fastly':
|
|
84
|
-
resp.headers.set('surrogate-control', `max-age=${cdnTTL}, stale-while-revalidate=0`);
|
|
85
|
-
break;
|
|
86
|
-
case 'akamai':
|
|
87
|
-
resp.headers.set('edge-control', `!no-store,max-age=${cdnTTL}s,downstream-ttl=${browserTTL}s`);
|
|
88
|
-
break;
|
|
89
|
-
case 'cloudfront':
|
|
90
|
-
resp.headers.set('cache-control', `max-age=${browserTTL}, s-maxage=${cdnTTL}, must-revalidate`);
|
|
91
|
-
break;
|
|
92
|
-
default:
|
|
93
|
-
break;
|
|
94
|
-
}
|
|
95
53
|
|
|
96
|
-
|
|
97
|
-
// apply cache tags: transform Fastly Surrogate-Key to Cloudflare Cache-Tag
|
|
98
|
-
if (cacheKeys.length) {
|
|
99
|
-
switch (byo_cdn_type) {
|
|
100
|
-
case 'fastly':
|
|
101
|
-
resp.headers.set('surrogate-key', cacheKeys.join(' '));
|
|
102
|
-
break;
|
|
103
|
-
case 'akamai':
|
|
104
|
-
resp.headers.set('edge-cache-tag', cacheKeys.join(' '));
|
|
105
|
-
break;
|
|
106
|
-
case 'cloudflare':
|
|
107
|
-
resp.headers.set('cache-tag', `${cacheKeys.join(',')}`);
|
|
108
|
-
break;
|
|
109
|
-
case 'cloudfront':
|
|
110
|
-
// cloudfront doesn't support cache tags/keys ...
|
|
111
|
-
break;
|
|
112
|
-
default:
|
|
113
|
-
break;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
54
|
+
applyByoCdnHeaders(resp, req, cacheKeys, browserTTL, cdnTTL);
|
|
116
55
|
}
|
|
117
56
|
|
|
118
57
|
/**
|
|
@@ -164,25 +103,6 @@ export async function setProductCacheHeaders(state, req, resp) {
|
|
|
164
103
|
org, site, contentBusId, info,
|
|
165
104
|
} = state;
|
|
166
105
|
|
|
167
|
-
const productKeys =
|
|
168
|
-
|
|
169
|
-
await computeSiteKey(org, site),
|
|
170
|
-
];
|
|
171
|
-
const authoredContentKey = await computeAuthoredContentKey(contentBusId, info.originalPath);
|
|
172
|
-
setCachingHeaders(req, resp, [...productKeys, authoredContentKey]);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Sets the cache headers for a product
|
|
177
|
-
* @param {PipelineState} state
|
|
178
|
-
* @param {PipelineRequest} req
|
|
179
|
-
* @param {PipelineResponse} resp
|
|
180
|
-
* @returns {Promise<void>}
|
|
181
|
-
*/
|
|
182
|
-
export async function setMediaCacheHeaders(state, req, resp) {
|
|
183
|
-
const { org, site } = state;
|
|
184
|
-
const url = new URL(req.url);
|
|
185
|
-
const keys = await computeMediaKeys(org, site, url.pathname);
|
|
186
|
-
|
|
187
|
-
setCachingHeaders(req, resp, keys);
|
|
106
|
+
const productKeys = await computeProductKeys(org, site, info.path, contentBusId);
|
|
107
|
+
setCachingHeaders(req, resp, productKeys);
|
|
188
108
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
const BYO_CDN_TYPES = ['akamai', 'cloudflare', 'fastly', 'cloudfront'];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Detects the BYO CDN type from the request headers.
|
|
17
|
+
*
|
|
18
|
+
* @param {Request|object} req - The request object (must have headers)
|
|
19
|
+
* @returns {string|undefined} - The detected CDN type or undefined
|
|
20
|
+
*/
|
|
21
|
+
const detectByoCdnType = (req) => {
|
|
22
|
+
const { headers } = req;
|
|
23
|
+
let byoCdnType = headers.get('x-byo-cdn-type');
|
|
24
|
+
const via = headers.get('via') || '';
|
|
25
|
+
const cdnLoop = headers.get('cdn-loop') || '';
|
|
26
|
+
|
|
27
|
+
if (!BYO_CDN_TYPES.includes(byoCdnType)) {
|
|
28
|
+
if (/[Aa]kamai/.test(via)) {
|
|
29
|
+
byoCdnType = 'akamai';
|
|
30
|
+
} else if (via.includes('varnish') || cdnLoop.startsWith('Fastly')) {
|
|
31
|
+
byoCdnType = 'fastly';
|
|
32
|
+
} else if (cdnLoop.includes('cloudflare') || headers.has('cf-worker')) {
|
|
33
|
+
byoCdnType = 'cloudflare';
|
|
34
|
+
} else if (via.includes('CloudFront')) {
|
|
35
|
+
byoCdnType = 'cloudfront';
|
|
36
|
+
} else {
|
|
37
|
+
byoCdnType = undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return BYO_CDN_TYPES.includes(byoCdnType) ? byoCdnType : undefined;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Applies BYO CDN specific cache headers to the response.
|
|
46
|
+
*
|
|
47
|
+
* @param {Response|object} resp - The response object
|
|
48
|
+
* @param {Request|object} req - The request object
|
|
49
|
+
* @param {string[]} cacheKeys - List of cache keys/tags
|
|
50
|
+
* @param {number} browserTTL - Browser TTL in seconds
|
|
51
|
+
* @param {number} cdnTTL - CDN TTL in seconds
|
|
52
|
+
*/
|
|
53
|
+
export const applyByoCdnHeaders = (resp, req, cacheKeys, browserTTL, cdnTTL) => {
|
|
54
|
+
const byoCdnType = detectByoCdnType(req);
|
|
55
|
+
const keys = cacheKeys && cacheKeys.length ? cacheKeys : [];
|
|
56
|
+
|
|
57
|
+
switch (byoCdnType) {
|
|
58
|
+
case 'cloudflare':
|
|
59
|
+
resp.headers.set('cdn-cache-control', `max-age=${cdnTTL}, must-revalidate`);
|
|
60
|
+
if (keys.length) resp.headers.set('cache-tag', keys.join(','));
|
|
61
|
+
break;
|
|
62
|
+
case 'fastly':
|
|
63
|
+
resp.headers.set('surrogate-control', `max-age=${cdnTTL}, stale-while-revalidate=0`);
|
|
64
|
+
if (keys.length) resp.headers.set('surrogate-key', keys.join(' '));
|
|
65
|
+
break;
|
|
66
|
+
case 'akamai':
|
|
67
|
+
resp.headers.set('edge-control', `!no-store,max-age=${cdnTTL}s,downstream-ttl=${browserTTL}s`);
|
|
68
|
+
if (keys.length) resp.headers.set('edge-cache-tag', keys.join(' '));
|
|
69
|
+
break;
|
|
70
|
+
case 'cloudfront':
|
|
71
|
+
resp.headers.set('cache-control', `max-age=${browserTTL}, s-maxage=${cdnTTL}, must-revalidate`);
|
|
72
|
+
break;
|
|
73
|
+
default:
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
@@ -1,64 +0,0 @@
|
|
|
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 { PipelineResponse, PipelineStatusError } from '@adobe/helix-html-pipeline';
|
|
14
|
-
import { validatePathInfo } from './utils/path.js';
|
|
15
|
-
import initConfig from './steps/init-config.js';
|
|
16
|
-
import fetchMedia from './steps/fetch-media.js';
|
|
17
|
-
import { setLastModified } from './utils/last-modified.js';
|
|
18
|
-
import { setMediaCacheHeaders } from './steps/set-cache-headers.js';
|
|
19
|
-
|
|
20
|
-
export async function productMediaPipe(state, req) {
|
|
21
|
-
state.type = 'media';
|
|
22
|
-
|
|
23
|
-
if (!validatePathInfo(state.info)) {
|
|
24
|
-
return new PipelineResponse('', {
|
|
25
|
-
status: 404,
|
|
26
|
-
headers: {
|
|
27
|
-
'x-error': 'invalid path',
|
|
28
|
-
},
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const res = new PipelineResponse('');
|
|
33
|
-
|
|
34
|
-
try {
|
|
35
|
-
await initConfig(state, req, res);
|
|
36
|
-
|
|
37
|
-
state.timer?.update('content-fetch');
|
|
38
|
-
await fetchMedia(state, res);
|
|
39
|
-
if (res.status === 404) {
|
|
40
|
-
return new PipelineResponse('', {
|
|
41
|
-
status: 404,
|
|
42
|
-
headers: {
|
|
43
|
-
'x-error': 'not found',
|
|
44
|
-
},
|
|
45
|
-
});
|
|
46
|
-
} else if (res.error) {
|
|
47
|
-
if (res.status < 400) {
|
|
48
|
-
return res;
|
|
49
|
-
}
|
|
50
|
-
throw new PipelineStatusError(res.status, res.error);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
setLastModified(state, res);
|
|
54
|
-
|
|
55
|
-
await setMediaCacheHeaders(state, req, res);
|
|
56
|
-
|
|
57
|
-
res.headers = state.content.headers;
|
|
58
|
-
res.body = state.content.data;
|
|
59
|
-
} catch (e) {
|
|
60
|
-
res.error = e.message;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return res;
|
|
64
|
-
}
|
package/src/steps/fetch-media.js
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Copyright 2022 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
|
-
import { extractLastModified, recordLastModified } from '../utils/last-modified.js';
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Loads the content from either the content-bus or code-bus and stores it in `state.content`
|
|
16
|
-
* @param {PipelineState} state
|
|
17
|
-
* @param {PipelineResponse} res
|
|
18
|
-
* @returns {Promise<void>}
|
|
19
|
-
*/
|
|
20
|
-
export default async function fetchMedia(state, res) {
|
|
21
|
-
const {
|
|
22
|
-
info, org, site,
|
|
23
|
-
} = state;
|
|
24
|
-
const bucketId = 'helix-product-bus';
|
|
25
|
-
|
|
26
|
-
const key = `${org}/${site}/media/${info.originalFilename}`;
|
|
27
|
-
const ret = await state.s3Loader.getObject(bucketId, key);
|
|
28
|
-
|
|
29
|
-
if (ret.status === 200) {
|
|
30
|
-
res.status = 200;
|
|
31
|
-
delete res.error;
|
|
32
|
-
state.content.data = ret.body;
|
|
33
|
-
state.content.headers = ret.headers;
|
|
34
|
-
|
|
35
|
-
recordLastModified(state, res, 'content', extractLastModified(ret.headers));
|
|
36
|
-
} else {
|
|
37
|
-
// keep 404, but propagate others as 502
|
|
38
|
-
res.status = ret.status === 404 ? 404 : 502;
|
|
39
|
-
res.error = `failed to load ${info.resourcePath} from product-bus: ${ret.status}`;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Copyright 2022 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
|
-
/* eslint-disable */
|
|
14
|
-
// @ts-nocheck
|
|
15
|
-
|
|
16
|
-
import { slugger } from '@dylandepass/helix-product-shared';
|
|
17
|
-
import { extractLastModified, recordLastModified } from '../utils/last-modified.js';
|
|
18
|
-
|
|
19
|
-
const INDEXER_URL_KEYS = ['index', 'merchant-center-feed', 'sitemap'];
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Loads the content from either the content-bus or code-bus and stores it in `state.content`
|
|
23
|
-
* @param {PipelineState} state
|
|
24
|
-
* @param {PipelineRequest} req
|
|
25
|
-
* @param {PipelineResponse} res
|
|
26
|
-
* @returns {Promise<void>}
|
|
27
|
-
*/
|
|
28
|
-
export default async function fetchContent(state, req, res) {
|
|
29
|
-
const {
|
|
30
|
-
info, owner, site, config,
|
|
31
|
-
} = state;
|
|
32
|
-
const bucketId = 'adobe-commerce-catalog';
|
|
33
|
-
const { route } = config;
|
|
34
|
-
const { storeCode, storeViewCode } = route;
|
|
35
|
-
|
|
36
|
-
let sku;
|
|
37
|
-
|
|
38
|
-
// if route params only contains the urlKey than we
|
|
39
|
-
// need to head first to get the sku
|
|
40
|
-
|
|
41
|
-
// conditioanlly strip .json and .xml off urlKey
|
|
42
|
-
let urlKey = route.params.urlKey?.replace(/\.json$/, '');
|
|
43
|
-
urlKey = urlKey?.replace(/\.xml$/, '');
|
|
44
|
-
|
|
45
|
-
if (Object.keys(route.params).length === 1 && urlKey && !INDEXER_URL_KEYS.includes(urlKey)) {
|
|
46
|
-
const headKey = `${owner}/${site}/${storeCode}/${storeViewCode}/urlkeys/${urlKey}`;
|
|
47
|
-
|
|
48
|
-
const headRes = await state.s3Loader.headObject(bucketId, headKey);
|
|
49
|
-
|
|
50
|
-
if (headRes.status === 200) {
|
|
51
|
-
sku = headRes.headers.get('sku');
|
|
52
|
-
} else {
|
|
53
|
-
res.status = headRes.status === 404 ? 404 : 502;
|
|
54
|
-
res.error = `HEAD: failed to load ${info.resourcePath} from product-bus: ${headRes.status}`;
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
} else if (!INDEXER_URL_KEYS.includes(urlKey)) {
|
|
58
|
-
sku = route.params.sku?.replace(/\.json$/, '').replace(/\.xml$/, '');
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** @type {string} */
|
|
62
|
-
let key;
|
|
63
|
-
if ((!sku && !INDEXER_URL_KEYS.includes(urlKey)) || (sku && !INDEXER_URL_KEYS.includes(sku))) {
|
|
64
|
-
const slug = slugger(sku);
|
|
65
|
-
key = `${owner}/${site}/${storeCode}/${storeViewCode}/products/${slug}.json`;
|
|
66
|
-
} else if (urlKey === 'index' || sku === 'index' || urlKey === 'sitemap' || sku === 'sitemap') {
|
|
67
|
-
const id = req.params?.id ?? 'default';
|
|
68
|
-
key = `${owner}/${site}/${storeCode}/${storeViewCode}/index/${id}.json`;
|
|
69
|
-
} else if (urlKey === 'merchant-center-feed' || sku === 'merchant-center-feed') {
|
|
70
|
-
key = `${owner}/${site}/${storeCode}/${storeViewCode}/merchant-feed/default.json`;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const ret = await state.s3Loader.getObject(bucketId, key);
|
|
74
|
-
|
|
75
|
-
if (ret.status === 200) {
|
|
76
|
-
try {
|
|
77
|
-
res.status = 200;
|
|
78
|
-
delete res.error;
|
|
79
|
-
state.content.data = JSON.parse(ret.body);
|
|
80
|
-
|
|
81
|
-
recordLastModified(state, res, 'content', extractLastModified(ret.headers));
|
|
82
|
-
} catch (e) {
|
|
83
|
-
res.status = 400;
|
|
84
|
-
res.error = `failed to parse ${info.resourcePath} from product-bus: ${e.message}`;
|
|
85
|
-
}
|
|
86
|
-
} else {
|
|
87
|
-
// keep 404, but propagate others as 502
|
|
88
|
-
res.status = ret.status === 404 ? 404 : 502;
|
|
89
|
-
res.error = `failed to load ${urlKey === 'index' ? key : info.resourcePath} from product-bus: ${ret.status}`;
|
|
90
|
-
}
|
|
91
|
-
}
|