Skip to content

Commit 21067ed

Browse files
jorgemoyaclaude
andcommitted
feat(core): add locale parameter to client.fetch() and harden beforeRequest
Add optional locale parameter to client.fetch() for use in cached contexts. Wrap headers() call in beforeRequest with try/catch so it gracefully degrades inside unstable_cache. No changes to getChannelId or beforeRequest callback signatures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a8dd99e commit 21067ed

4 files changed

Lines changed: 33 additions & 12 deletions

File tree

.changeset/locale-param-client.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@bigcommerce/catalyst-client": patch
3+
---
4+
5+
Add optional `locale` parameter to `client.fetch()`. This allows locale to be passed explicitly for use in cached contexts where `getLocale()` is unavailable.

.changeset/locale-param-core.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@bigcommerce/catalyst-core": patch
3+
---
4+
5+
Wrap IP forwarding headers (`X-Forwarded-For`, `True-Client-IP`) in try/catch for safety inside `unstable_cache` contexts where `headers()` is unavailable.

core/client/index.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,24 +41,28 @@ export const client = createClient({
4141
logger:
4242
(process.env.NODE_ENV !== 'production' && process.env.CLIENT_LOGGER !== 'false') ||
4343
process.env.CLIENT_LOGGER === 'true',
44-
getChannelId: async (defaultChannelId: string) => {
45-
const locale = await getLocale();
44+
getChannelId: async (defaultChannelId: string, locale?: string) => {
45+
const resolvedLocale = locale ?? (await getLocale());
4646

4747
// We use the default channelId as a fallback, but it is not ideal in some scenarios.
48-
return getChannelIdFromLocale(locale) ?? defaultChannelId;
48+
return getChannelIdFromLocale(resolvedLocale) ?? defaultChannelId;
4949
},
5050
beforeRequest: async (fetchOptions) => {
5151
// We can't serialize a `Headers` object within this method so we have to opt into using a plain object
5252
const requestHeaders: Record<string, string> = {};
5353
const locale = await getLocale();
5454

5555
if (fetchOptions?.cache && ['no-store', 'no-cache'].includes(fetchOptions.cache)) {
56-
const { headers } = await import('next/headers');
57-
const ipAddress = (await headers()).get('X-Forwarded-For');
56+
try {
57+
const { headers } = await import('next/headers');
58+
const ipAddress = (await headers()).get('X-Forwarded-For');
5859

59-
if (ipAddress) {
60-
requestHeaders['X-Forwarded-For'] = ipAddress;
61-
requestHeaders['True-Client-IP'] = ipAddress;
60+
if (ipAddress) {
61+
requestHeaders['X-Forwarded-For'] = ipAddress;
62+
requestHeaders['True-Client-IP'] = ipAddress;
63+
}
64+
} catch {
65+
// headers() is unavailable inside unstable_cache / 'use cache' contexts
6266
}
6367
}
6468

packages/client/src/client.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ type GraphQLErrorPolicy = 'none' | 'all' | 'auth' | 'ignore';
4949
class Client<FetcherRequestInit extends RequestInit = RequestInit> {
5050
private backendUserAgent: string;
5151
private readonly defaultChannelId: string;
52-
private getChannelId: (defaultChannelId: string) => Promise<string> | string;
52+
private getChannelId: (defaultChannelId: string, locale?: string) => Promise<string> | string;
5353
private beforeRequest?: (
5454
fetchOptions?: FetcherRequestInit,
5555
) => Promise<Partial<FetcherRequestInit> | undefined> | Partial<FetcherRequestInit> | undefined;
@@ -85,6 +85,7 @@ class Client<FetcherRequestInit extends RequestInit = RequestInit> {
8585
customerAccessToken?: string;
8686
fetchOptions?: FetcherRequestInit;
8787
channelId?: string;
88+
locale?: string;
8889
errorPolicy?: GraphQLErrorPolicy;
8990
validateCustomerAccessToken?: boolean;
9091
}): Promise<BigCommerceResponse<TResult>>;
@@ -96,6 +97,7 @@ class Client<FetcherRequestInit extends RequestInit = RequestInit> {
9697
customerAccessToken?: string;
9798
fetchOptions?: FetcherRequestInit;
9899
channelId?: string;
100+
locale?: string;
99101
errorPolicy?: GraphQLErrorPolicy;
100102
validateCustomerAccessToken?: boolean;
101103
}): Promise<BigCommerceResponse<TResult>>;
@@ -106,6 +108,7 @@ class Client<FetcherRequestInit extends RequestInit = RequestInit> {
106108
customerAccessToken,
107109
fetchOptions = {} as FetcherRequestInit,
108110
channelId,
111+
locale,
109112
errorPolicy = 'none',
110113
validateCustomerAccessToken = true,
111114
}: {
@@ -114,6 +117,7 @@ class Client<FetcherRequestInit extends RequestInit = RequestInit> {
114117
customerAccessToken?: string;
115118
fetchOptions?: FetcherRequestInit;
116119
channelId?: string;
120+
locale?: string;
117121
errorPolicy?: GraphQLErrorPolicy;
118122
validateCustomerAccessToken?: boolean;
119123
}): Promise<BigCommerceResponse<TResult>> {
@@ -126,6 +130,7 @@ class Client<FetcherRequestInit extends RequestInit = RequestInit> {
126130
channelId,
127131
operationInfo.name,
128132
operationInfo.type,
133+
locale,
129134
);
130135
const { headers: additionalFetchHeaders = {}, ...additionalFetchOptions } =
131136
(await this.beforeRequest?.(fetchOptions)) ?? {};
@@ -143,6 +148,7 @@ class Client<FetcherRequestInit extends RequestInit = RequestInit> {
143148
...(this.trustedProxySecret && { 'X-BC-Trusted-Proxy-Secret': this.trustedProxySecret }),
144149
...Object.fromEntries(new Headers(additionalFetchHeaders).entries()),
145150
...Object.fromEntries(new Headers(headers).entries()),
151+
...(locale && { 'Accept-Language': locale }),
146152
},
147153
body: JSON.stringify({
148154
query,
@@ -210,8 +216,8 @@ class Client<FetcherRequestInit extends RequestInit = RequestInit> {
210216
return response.text();
211217
}
212218

213-
private async getCanonicalUrl(channelId?: string) {
214-
const resolvedChannelId = channelId ?? (await this.getChannelId(this.defaultChannelId));
219+
private async getCanonicalUrl(channelId?: string, locale?: string) {
220+
const resolvedChannelId = channelId ?? (await this.getChannelId(this.defaultChannelId, locale));
215221

216222
return `https://store-${this.config.storeHash}-${resolvedChannelId}.${graphqlApiDomain}`;
217223
}
@@ -220,8 +226,9 @@ class Client<FetcherRequestInit extends RequestInit = RequestInit> {
220226
channelId?: string,
221227
operationName?: string,
222228
operationType?: string,
229+
locale?: string,
223230
) {
224-
const baseUrl = new URL(`${await this.getCanonicalUrl(channelId)}/graphql`);
231+
const baseUrl = new URL(`${await this.getCanonicalUrl(channelId, locale)}/graphql`);
225232

226233
if (operationName) {
227234
baseUrl.searchParams.set('operation', operationName);

0 commit comments

Comments
 (0)