Skip to content

Commit 1d297f7

Browse files
committed
Move sitemap client into a package
1 parent 5ab180f commit 1d297f7

File tree

12 files changed

+289
-130
lines changed

12 files changed

+289
-130
lines changed

core/app/sitemap.xml/route.ts

Lines changed: 14 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -3,83 +3,24 @@
33
* Proxy to the existing BigCommerce sitemap index on the canonical URL
44
*/
55

6+
import { createSitemapClient, handleSitemapRoute } from '@bigcommerce/catalyst-sitemap-client';
7+
68
import { getChannelIdFromLocale } from '~/channels.config';
7-
import { client } from '~/client';
89
import { defaultLocale } from '~/i18n/locales';
910

1011
export const GET = async (request: Request) => {
11-
const url = new URL(request.url);
12-
const incomingHost = request.headers.get('host') ?? url.host;
13-
const incomingProto = request.headers.get('x-forwarded-proto') ?? url.protocol.replace(':', '');
14-
15-
const type = url.searchParams.get('type');
16-
const page = url.searchParams.get('page');
17-
18-
// If a specific sitemap within the index is requested, require both params
19-
if (type !== null || page !== null) {
20-
if (!type || !page) {
21-
return new Response('Both "type" and "page" query params are required', {
22-
status: 400,
23-
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
24-
});
25-
}
26-
27-
const upstream = await client.fetchSitemapResponse(
28-
{ type, page },
29-
getChannelIdFromLocale(defaultLocale),
30-
);
31-
32-
// Pass-through upstream status/body but enforce XML content-type
33-
const body = await upstream.text();
34-
35-
return new Response(body, {
36-
status: upstream.status,
37-
statusText: upstream.statusText,
38-
headers: { 'Content-Type': 'application/xml' },
39-
});
40-
}
41-
42-
// Otherwise, return the sitemap index with normalized internal links
43-
const sitemapIndex = await client.fetchSitemapIndex(getChannelIdFromLocale(defaultLocale));
44-
45-
const rewritten = sitemapIndex.replace(
46-
/<loc>([^<]+)<\/loc>/g,
47-
(match: string, locUrlStr: string) => {
48-
try {
49-
// Decode XML entities for '&' so URL parsing works
50-
const decoded: string = locUrlStr.replace(/&amp;/g, '&');
51-
const original = new URL(decoded);
52-
53-
if (!original.pathname.endsWith('/xmlsitemap.php')) {
54-
return match;
55-
}
56-
57-
const normalized = new URL(`${incomingProto}://${incomingHost}/sitemap.xml`);
58-
59-
const t = original.searchParams.get('type');
60-
const p = original.searchParams.get('page');
61-
62-
// Only rewrite entries that include both type and page; otherwise leave untouched
63-
if (!t || !p) {
64-
return match;
65-
}
66-
67-
normalized.searchParams.set('type', t);
68-
normalized.searchParams.set('page', p);
69-
70-
// Re-encode '&' for XML output
71-
const normalizedXml: string = normalized.toString().replace(/&/g, '&amp;');
72-
73-
return `<loc>${normalizedXml}</loc>`;
74-
} catch {
75-
return match;
76-
}
77-
},
78-
);
12+
const channelId =
13+
getChannelIdFromLocale(defaultLocale) ?? process.env.BIGCOMMERCE_CHANNEL_ID ?? '';
14+
const sitemapClient = createSitemapClient({
15+
storeHash: process.env.BIGCOMMERCE_STORE_HASH ?? '',
16+
getChannelId: () => channelId,
17+
graphqlApiDomain: process.env.BIGCOMMERCE_GRAPHQL_API_DOMAIN ?? 'mybigcommerce.com',
18+
trustedProxySecret: process.env.BIGCOMMERCE_TRUSTED_PROXY_SECRET,
19+
userAgent: `${process.env.NEXT_RUNTIME ?? 'node'}; Catalyst`,
20+
});
7921

80-
return new Response(rewritten, {
81-
headers: {
82-
'Content-Type': 'application/xml',
83-
},
22+
return handleSitemapRoute(request, {
23+
getSitemapIndex: () => sitemapClient.fetchSitemapIndex(),
24+
fetchSitemapResponse: ({ type, page }) => sitemapClient.fetchSitemapResponse({ type, page }),
8425
});
8526
};

core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
},
1515
"dependencies": {
1616
"@bigcommerce/catalyst-client": "workspace:^",
17+
"@bigcommerce/catalyst-sitemap-client": "workspace:^",
1718
"@conform-to/react": "^1.6.1",
1819
"@conform-to/zod": "^1.6.1",
1920
"@icons-pack/react-simple-icons": "^11.2.0",
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
dist
2+
3+
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
module.exports = {
2+
root: true,
3+
extends: ['@bigcommerce/eslint-config-catalyst/base'],
4+
parserOptions: {
5+
project: ['./tsconfig.eslint.json'],
6+
},
7+
};
8+
9+
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
## @bigcommerce/catalyst-sitemap-client
2+
3+
A small helper to handle Catalyst's `/sitemap.xml` route with:
4+
- Sitemap index rewriting to the incoming host and normalized path `/sitemap.xml`, only when both `type` and `page` are present
5+
- Proxying of specific sitemap pages when `type` and `page` are provided on the query string
6+
7+
### Usage
8+
9+
```ts
10+
// core/app/sitemap.xml/route.ts
11+
import { handleSitemapRoute } from '@bigcommerce/catalyst-sitemap-client';
12+
import { client } from '~/client';
13+
import { defaultLocale } from '~/i18n/locales';
14+
import { getChannelIdFromLocale } from '~/channels.config';
15+
16+
export const GET = async (request: Request) =>
17+
handleSitemapRoute(request, {
18+
getSitemapIndex: () => client.fetchSitemapIndex(getChannelIdFromLocale(defaultLocale)),
19+
fetchSitemapResponse: ({ type, page }) =>
20+
client.fetchSitemapResponse({ type, page }, getChannelIdFromLocale(defaultLocale)),
21+
});
22+
```
23+
24+
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"name": "@bigcommerce/catalyst-sitemap-client",
3+
"version": "1.0.0",
4+
"private": true,
5+
"main": "dist/index.js",
6+
"types": "dist/index.d.ts",
7+
"files": [
8+
"dist"
9+
],
10+
"scripts": {
11+
"build": "tsup",
12+
"lint": "eslint . --ext .ts,.js --max-warnings 0",
13+
"typecheck": "tsc --noEmit"
14+
},
15+
"devDependencies": {
16+
"tsup": "^8.5.0",
17+
"typescript": "^5.8.3",
18+
"eslint": "^8.57.1",
19+
"@bigcommerce/eslint-config-catalyst": "workspace:^"
20+
}
21+
}
22+
23+
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
export interface FetchSitemapParams {
2+
type?: string | null;
3+
page?: string | number | null;
4+
}
5+
6+
export interface HandleSitemapRouteOptions {
7+
getSitemapIndex: () => Promise<string>;
8+
fetchSitemapResponse: (params: FetchSitemapParams) => Promise<Response>;
9+
}
10+
11+
export interface SitemapClientOptions {
12+
storeHash: string;
13+
getChannelId: () => Promise<string> | string;
14+
graphqlApiDomain?: string; // default: mybigcommerce.com
15+
trustedProxySecret?: string;
16+
userAgent?: string;
17+
}
18+
19+
export function createSitemapClient(options: SitemapClientOptions) {
20+
const graphqlDomain = options.graphqlApiDomain ?? 'mybigcommerce.com';
21+
22+
async function getCanonicalUrl() {
23+
const channelId = await options.getChannelId();
24+
25+
return `https://store-${options.storeHash}-${channelId}.${graphqlDomain}`;
26+
}
27+
28+
return {
29+
async fetchSitemapIndex(): Promise<string> {
30+
const base = await getCanonicalUrl();
31+
const url = `${base}/xmlsitemap.php`;
32+
const headers = new Headers();
33+
34+
headers.set('Accept', 'application/xml');
35+
headers.set('Content-Type', 'application/xml');
36+
if (options.userAgent) headers.set('User-Agent', options.userAgent);
37+
if (options.trustedProxySecret)
38+
headers.set('X-BC-Trusted-Proxy-Secret', options.trustedProxySecret);
39+
40+
const response = await fetch(url, { method: 'GET', headers });
41+
42+
if (!response.ok) {
43+
throw new Error(`Unable to get Sitemap Index: ${response.statusText}`);
44+
}
45+
46+
return response.text();
47+
},
48+
49+
async fetchSitemapResponse(params: FetchSitemapParams): Promise<Response> {
50+
const base = await getCanonicalUrl();
51+
const url = new URL(`${base}/xmlsitemap.php`);
52+
53+
if (params.type) url.searchParams.set('type', String(params.type));
54+
if (params.page !== undefined && params.page !== null)
55+
url.searchParams.set('page', String(params.page));
56+
57+
const headers = new Headers();
58+
59+
headers.set('Accept', 'application/xml');
60+
headers.set('Content-Type', 'application/xml');
61+
if (options.userAgent) headers.set('User-Agent', options.userAgent);
62+
if (options.trustedProxySecret)
63+
headers.set('X-BC-Trusted-Proxy-Secret', options.trustedProxySecret);
64+
65+
const response = await fetch(url.toString(), { method: 'GET', headers });
66+
67+
return response;
68+
},
69+
} as const;
70+
}
71+
72+
/**
73+
* Handles GET /sitemap.xml with two behaviors:
74+
* - Without query params: returns the sitemap index with <loc> links rewritten to the incoming host and
75+
* normalized to /sitemap.xml, only when both type and page exist on the original link.
76+
* - With query params: requires both type and page, proxies upstream response as-is (status/body),
77+
* ignoring any other params.
78+
*/
79+
/**
80+
* Handles the Catalyst sitemap route.
81+
*
82+
* @param {Request} request Incoming Next.js Request for `/sitemap.xml`.
83+
* @param {any} options Helpers to fetch the upstream sitemap resources.
84+
* @returns {Promise<Response>} Response suitable for returning from the route handler.
85+
*/
86+
export async function handleSitemapRoute(
87+
request: Request,
88+
options: HandleSitemapRouteOptions,
89+
): Promise<Response> {
90+
const { getSitemapIndex, fetchSitemapResponse } = options;
91+
const requestUrl = new URL(request.url);
92+
const incomingHost: string = request.headers.get('host') ?? requestUrl.host;
93+
const incomingProtoHeader = request.headers.get('x-forwarded-proto');
94+
const incomingProtocol: string = incomingProtoHeader ?? requestUrl.protocol.replace(':', '');
95+
96+
const typeParam = requestUrl.searchParams.get('type');
97+
const pageParam = requestUrl.searchParams.get('page');
98+
99+
// If any sitemap page is requested, require both well-known params
100+
if (typeParam !== null || pageParam !== null) {
101+
if (!typeParam || !pageParam) {
102+
return new Response('Both "type" and "page" query params are required', {
103+
status: 400,
104+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
105+
});
106+
}
107+
108+
const upstream = await fetchSitemapResponse({ type: typeParam, page: pageParam });
109+
const body = await upstream.text();
110+
111+
return new Response(body, {
112+
status: upstream.status,
113+
statusText: upstream.statusText,
114+
headers: { 'Content-Type': 'application/xml' },
115+
});
116+
}
117+
118+
const sitemapIndexXml = await getSitemapIndex();
119+
120+
// Rewrite <loc> links to use incoming host and normalized path, only when both type & page exist
121+
const rewrittenXml = sitemapIndexXml.replace(
122+
/<loc>([^<]+)<\/loc>/g,
123+
(match: string, locUrlStr: string) => {
124+
try {
125+
const decoded: string = locUrlStr.replace(/&amp;/g, '&');
126+
const original = new URL(decoded);
127+
128+
if (!original.pathname.endsWith('/xmlsitemap.php')) {
129+
return match;
130+
}
131+
132+
const normalized = new URL(`${incomingProtocol}://${incomingHost}/sitemap.xml`);
133+
134+
const t = original.searchParams.get('type');
135+
const p = original.searchParams.get('page');
136+
137+
if (!t || !p) {
138+
return match;
139+
}
140+
141+
normalized.searchParams.set('type', t);
142+
normalized.searchParams.set('page', p);
143+
144+
const normalizedXml: string = normalized.toString().replace(/&/g, '&amp;');
145+
146+
return `<loc>${normalizedXml}</loc>`;
147+
} catch {
148+
return match;
149+
}
150+
},
151+
);
152+
153+
return new Response(rewrittenXml, {
154+
headers: {
155+
'Content-Type': 'application/xml',
156+
},
157+
});
158+
}
159+
160+
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"include": [
4+
"src/**/*",
5+
"tsup.config.ts"
6+
]
7+
}
8+
9+
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2020",
4+
"module": "CommonJS",
5+
"moduleResolution": "Node",
6+
"strict": true,
7+
"esModuleInterop": true,
8+
"forceConsistentCasingInFileNames": true,
9+
"skipLibCheck": true,
10+
"declaration": true,
11+
"outDir": "dist"
12+
},
13+
"include": ["src/**/*"]
14+
}
15+
16+
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { defineConfig, Options } from 'tsup';
2+
3+
export default defineConfig((options: Options) => ({
4+
entry: ['src/index.ts'],
5+
format: ['cjs'],
6+
dts: true,
7+
clean: !options.watch,
8+
...options,
9+
}));
10+
11+

0 commit comments

Comments
 (0)