cloudflare-next-intl 0.8.39 → 0.8.41

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/README.md CHANGED
@@ -389,9 +389,10 @@ export default setIntlConfig({
389
389
  privacyPolicyDate: "2026-01-01",
390
390
  // privacyPolicyPath: "/privacy-policy", // default; used by the
391
391
  // dialogs' auto-rendered link. Set false to disable that link.
392
- // Optional: gate the banner to GDPR-region visitors only. Omit both
393
- // getCountryCode and generate.getCloudflareContext to disable
394
- // country-based gating (consent always implicit).
392
+ // country-based gating is enabled by default (reads Cloudflare geo
393
+ // headers; override via countryHeaderNames). Visitors outside GDPR
394
+ // regions skip the banner and get consent immediately.
395
+ // countryHeaderNames: ["x-cf-country", "cf-ipcountry"],
395
396
  // gdprCountries: [...], // defaults to EU/EEA + UK + Switzerland
396
397
  // enableAnalyticsInDevMode: true, // analytics stay off in dev otherwise
397
398
  // autoWireDialogs: false, // opt out and render the dialogs yourself
@@ -5,4 +5,4 @@ import type { CookieConsentGetCloudflareContext, ErrorHandlingRoutingConfig } fr
5
5
  * (nFADP). ISO 3166-1 alpha-2.
6
6
  */
7
7
  export declare const defaultGdprCountries: readonly string[];
8
- export default function resolveRequiresConsent(getCountryCode: (() => string | undefined | Promise<string | undefined>) | undefined, getCloudflareContext: CookieConsentGetCloudflareContext | undefined, gdprCountries: readonly string[] | undefined, errorHandlingConfig?: ErrorHandlingRoutingConfig): Promise<boolean>;
8
+ export default function resolveRequiresConsent(getCountryCode: (() => string | undefined | Promise<string | undefined>) | undefined, getCloudflareContext: CookieConsentGetCloudflareContext | undefined, gdprCountries: readonly string[] | undefined, errorHandlingConfig?: ErrorHandlingRoutingConfig, countryHeaderNames?: readonly string[]): Promise<boolean>;
@@ -1,4 +1,5 @@
1
1
  import reportError from '../error_handling/report_error';
2
+ import { getCountry } from '../server/functions/geo';
2
3
  /**
3
4
  * Default `cookieConsent.gdprCountries` — EU/EEA member states (GDPR),
4
5
  * Iceland/Liechtenstein/Norway (EEA), the UK (UK-GDPR), and Switzerland
@@ -31,14 +32,12 @@ function getGdprCountriesSet(gdprCountries) {
31
32
  }
32
33
  return set;
33
34
  }
34
- export default async function resolveRequiresConsent(getCountryCode, getCloudflareContext, gdprCountries, errorHandlingConfig) {
35
- if (!getCountryCode && !getCloudflareContext)
36
- return true;
35
+ export default async function resolveRequiresConsent(getCountryCode, getCloudflareContext, gdprCountries, errorHandlingConfig, countryHeaderNames) {
37
36
  let countryCode;
38
37
  if (getCountryCode) {
39
38
  countryCode = await getCountryCode();
40
39
  }
41
- else {
40
+ else if (getCloudflareContext) {
42
41
  try {
43
42
  countryCode = (await getCloudflareContext({ async: true }))?.cf?.country;
44
43
  }
@@ -47,6 +46,17 @@ export default async function resolveRequiresConsent(getCountryCode, getCloudfla
47
46
  return true;
48
47
  }
49
48
  }
49
+ // Neither getter supplied (or one resolved nothing): fall back to the
50
+ // package's own geo resolution, which reads the Cloudflare country
51
+ // headers off the current request.
52
+ if (typeof countryCode !== 'string' || !countryCode) {
53
+ try {
54
+ countryCode = await getCountry(undefined, undefined, countryHeaderNames);
55
+ }
56
+ catch {
57
+ return true;
58
+ }
59
+ }
50
60
  if (typeof countryCode !== 'string' || !countryCode)
51
61
  return true;
52
62
  return getGdprCountriesSet(gdprCountries).has(countryCode);
@@ -144,7 +144,7 @@ export default async function LocationzationProvider({ language, messages, stati
144
144
  // `getCloudflareContext` path in dev; fail-safe to `true`
145
145
  // (banner shown) same as an unresolved country would.
146
146
  requiresConsent = !isDevEnvironment
147
- ? await resolveRequiresConsent(config.cookieConsent.getCountryCode, config.generate?.getCloudflareContext, config.cookieConsent.gdprCountries, config.errorHandling)
147
+ ? await resolveRequiresConsent(config.cookieConsent.getCountryCode, config.generate?.getCloudflareContext, config.cookieConsent.gdprCountries, config.errorHandling, config.cookieConsent.countryHeaderNames)
148
148
  : false;
149
149
  const analyticsAllowedInEnv = config.cookieConsent.enableAnalyticsInDevMode === true || !isDevEnvironment;
150
150
  if (config.cookieConsent.autoWireAnalytics !== false && analyticsAllowedInEnv) {
@@ -87,7 +87,7 @@ export default async function LocationzationProvider({ language, messages, child
87
87
  // `getCloudflareContext` path in dev; fail-safe to `true`
88
88
  // (banner shown) same as an unresolved country would.
89
89
  requiresConsent = !isDevEnvironment
90
- ? await resolveRequiresConsent(config.cookieConsent.getCountryCode, config.generate?.getCloudflareContext, config.cookieConsent.gdprCountries, config.errorHandling)
90
+ ? await resolveRequiresConsent(config.cookieConsent.getCountryCode, config.generate?.getCloudflareContext, config.cookieConsent.gdprCountries, config.errorHandling, config.cookieConsent.countryHeaderNames)
91
91
  : false;
92
92
  const analyticsAllowedInEnv = config.cookieConsent.enableAnalyticsInDevMode === true || !isDevEnvironment;
93
93
  if (config.cookieConsent.autoWireAnalytics !== false && analyticsAllowedInEnv) {
@@ -1,24 +1,30 @@
1
1
  import type { GenerateRoutingConfig, RequestOrHeaders } from '../../types/types';
2
+ /** Default request headers read to resolve the visitor's country, in order. */
3
+ export declare const defaultCountryHeaderNames: readonly string[];
4
+ /** Default request headers read to resolve the visitor's timezone, in order. */
5
+ export declare const defaultTimezoneHeaderNames: readonly string[];
2
6
  /**
3
7
  * Resolves the client's ISO 3166-1 alpha-2 country code (e.g. "US", "DE", "UA").
4
8
  *
5
9
  * Checks in order:
6
10
  * 1. Explicit `input` (Request, NextRequest, or Headers) if provided
7
- * 2. Next.js request headers via `headers()` (`x-cf-country`, `cf-ipcountry`)
8
- * 3. `config.generate.getCloudflareContext` or `cf.country` if configured
11
+ * 2. Next.js request headers via `headers()` (`headerNames`, default
12
+ * `x-cf-country`, `cf-ipcountry`)
13
+ * 3. `generate.getCloudflareContext` or `cf.country` if passed
9
14
  * 4. `undefined` if outside request scope or unavailable
10
15
  */
11
- export declare function getCountry(input?: RequestOrHeaders): Promise<string | undefined>;
16
+ export declare function getCountry(input?: RequestOrHeaders, generate?: GenerateRoutingConfig, headerNames?: readonly string[]): Promise<string | undefined>;
12
17
  /**
13
18
  * Resolves the client's IANA timezone string (e.g. "America/New_York", "Europe/Kyiv", "UTC").
14
19
  *
15
20
  * Checks in order:
16
21
  * 1. Explicit `input` (Request, NextRequest, or Headers) if provided
17
- * 2. Next.js request headers via `headers()` (`x-cf-timezone`, `cf-timezone`)
18
- * 3. `config.generate.getCloudflareContext` or `cf.timezone` if configured
22
+ * 2. Next.js request headers via `headers()` (`headerNames`, default
23
+ * `x-cf-timezone`, `cf-timezone`)
24
+ * 3. `generate.getCloudflareContext` or `cf.timezone` if passed
19
25
  * 4. `fallback` (or `undefined`) if outside request scope or unavailable
20
26
  */
21
- export declare function getTimezone(input?: RequestOrHeaders, fallback?: string): Promise<string | undefined>;
27
+ export declare function getTimezone(input?: RequestOrHeaders, fallback?: string, generate?: GenerateRoutingConfig, headerNames?: readonly string[]): Promise<string | undefined>;
22
28
  /**
23
29
  * Resolves the Cloudflare environment bindings object from `generate.env` or `generate.getCloudflareContext`.
24
30
  */
@@ -1,4 +1,7 @@
1
- import config from '../../config/intl_config';
1
+ /** Default request headers read to resolve the visitor's country, in order. */
2
+ export const defaultCountryHeaderNames = ['x-cf-country', 'cf-ipcountry'];
3
+ /** Default request headers read to resolve the visitor's timezone, in order. */
4
+ export const defaultTimezoneHeaderNames = ['x-cf-timezone', 'cf-timezone'];
2
5
  function extractHeader(h, name) {
3
6
  if (typeof h.get === 'function') {
4
7
  const val = h.get(name);
@@ -8,24 +11,49 @@ function extractHeader(h, name) {
8
11
  const val = rec[name] ?? rec[name.toLowerCase()];
9
12
  return (typeof val === 'string' && val.length > 0) ? val : undefined;
10
13
  }
14
+ // Read lazily (and tolerantly): `@intl-config` may not be set at all in
15
+ // standalone/unit usage of these helpers, and importing the config eagerly
16
+ // would risk a cycle with a config module that itself imports from here.
17
+ async function configuredHeaderNames(key) {
18
+ try {
19
+ const config = (await import('../../config/intl_config')).default;
20
+ return config?.generate?.[key];
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ }
26
+ function extractFromHeaderNames(h, headerNames) {
27
+ for (const name of headerNames) {
28
+ const val = extractHeader(h, name);
29
+ if (val)
30
+ return val;
31
+ }
32
+ return undefined;
33
+ }
11
34
  /**
12
35
  * Resolves the client's ISO 3166-1 alpha-2 country code (e.g. "US", "DE", "UA").
13
36
  *
14
37
  * Checks in order:
15
38
  * 1. Explicit `input` (Request, NextRequest, or Headers) if provided
16
- * 2. Next.js request headers via `headers()` (`x-cf-country`, `cf-ipcountry`)
17
- * 3. `config.generate.getCloudflareContext` or `cf.country` if configured
39
+ * 2. Next.js request headers via `headers()` (`headerNames`, default
40
+ * `x-cf-country`, `cf-ipcountry`)
41
+ * 3. `generate.getCloudflareContext` or `cf.country` if passed
18
42
  * 4. `undefined` if outside request scope or unavailable
19
43
  */
20
- export async function getCountry(input) {
44
+ export async function getCountry(input, generate, headerNames) {
45
+ const names = headerNames
46
+ ?? generate?.countryHeaderNames
47
+ ?? await configuredHeaderNames('countryHeaderNames')
48
+ ?? defaultCountryHeaderNames;
21
49
  if (input) {
22
50
  if ('headers' in input && input.headers) {
23
- const country = extractHeader(input.headers, 'x-cf-country') ?? extractHeader(input.headers, 'cf-ipcountry');
51
+ const country = extractFromHeaderNames(input.headers, names);
24
52
  if (country)
25
53
  return country;
26
54
  }
27
55
  else if (typeof input.get === 'function') {
28
- const country = input.get('x-cf-country') ?? input.get('cf-ipcountry') ?? undefined;
56
+ const country = extractFromHeaderNames(input, names);
29
57
  if (country)
30
58
  return country;
31
59
  }
@@ -37,16 +65,16 @@ export async function getCountry(input) {
37
65
  try {
38
66
  const { headers } = await import('next/headers');
39
67
  const h = await headers();
40
- const country = h.get('x-cf-country') ?? h.get('cf-ipcountry') ?? undefined;
68
+ const country = extractFromHeaderNames(h, names);
41
69
  if (country)
42
70
  return country;
43
71
  }
44
72
  catch {
45
73
  // Outside request scope / build time
46
74
  }
47
- if (config?.generate?.getCloudflareContext) {
75
+ if (generate?.getCloudflareContext) {
48
76
  try {
49
- const ctx = await config.generate.getCloudflareContext({ async: true });
77
+ const ctx = await generate.getCloudflareContext({ async: true });
50
78
  if (ctx?.cf?.country && typeof ctx.cf.country === 'string' && ctx.cf.country.length > 0) {
51
79
  return ctx.cf.country;
52
80
  }
@@ -62,19 +90,24 @@ export async function getCountry(input) {
62
90
  *
63
91
  * Checks in order:
64
92
  * 1. Explicit `input` (Request, NextRequest, or Headers) if provided
65
- * 2. Next.js request headers via `headers()` (`x-cf-timezone`, `cf-timezone`)
66
- * 3. `config.generate.getCloudflareContext` or `cf.timezone` if configured
93
+ * 2. Next.js request headers via `headers()` (`headerNames`, default
94
+ * `x-cf-timezone`, `cf-timezone`)
95
+ * 3. `generate.getCloudflareContext` or `cf.timezone` if passed
67
96
  * 4. `fallback` (or `undefined`) if outside request scope or unavailable
68
97
  */
69
- export async function getTimezone(input, fallback) {
98
+ export async function getTimezone(input, fallback, generate, headerNames) {
99
+ const names = headerNames
100
+ ?? generate?.timezoneHeaderNames
101
+ ?? await configuredHeaderNames('timezoneHeaderNames')
102
+ ?? defaultTimezoneHeaderNames;
70
103
  if (input) {
71
104
  if ('headers' in input && input.headers) {
72
- const tz = extractHeader(input.headers, 'x-cf-timezone') ?? extractHeader(input.headers, 'cf-timezone');
105
+ const tz = extractFromHeaderNames(input.headers, names);
73
106
  if (tz)
74
107
  return tz;
75
108
  }
76
109
  else if (typeof input.get === 'function') {
77
- const tz = input.get('x-cf-timezone') ?? input.get('cf-timezone') ?? undefined;
110
+ const tz = extractFromHeaderNames(input, names);
78
111
  if (tz)
79
112
  return tz;
80
113
  }
@@ -86,16 +119,16 @@ export async function getTimezone(input, fallback) {
86
119
  try {
87
120
  const { headers } = await import('next/headers');
88
121
  const h = await headers();
89
- const tz = h.get('x-cf-timezone') ?? h.get('cf-timezone') ?? undefined;
122
+ const tz = extractFromHeaderNames(h, names);
90
123
  if (tz)
91
124
  return tz;
92
125
  }
93
126
  catch {
94
127
  // Outside request scope
95
128
  }
96
- if (config?.generate?.getCloudflareContext) {
129
+ if (generate?.getCloudflareContext) {
97
130
  try {
98
- const ctx = await config.generate.getCloudflareContext({ async: true });
131
+ const ctx = await generate.getCloudflareContext({ async: true });
99
132
  if (ctx?.cf?.timezone && typeof ctx.cf.timezone === 'string' && ctx.cf.timezone.length > 0) {
100
133
  return ctx.cf.timezone;
101
134
  }
@@ -110,15 +143,15 @@ export async function getTimezone(input, fallback) {
110
143
  * Resolves the Cloudflare environment bindings object from `generate.env` or `generate.getCloudflareContext`.
111
144
  */
112
145
  export async function resolveEnv(generate) {
113
- const gen = generate ?? config?.generate;
114
- if (!gen)
146
+ if (!generate)
115
147
  return undefined;
116
- if (gen.env) {
117
- return typeof gen.env === 'function' ? await gen.env() : gen.env;
148
+ if (generate.env) {
149
+ const resolved = typeof generate.env === 'function' ? await generate.env() : generate.env;
150
+ return resolved;
118
151
  }
119
- if (gen.getCloudflareContext) {
152
+ if (generate.getCloudflareContext) {
120
153
  try {
121
- const ctx = await gen.getCloudflareContext({ async: true });
154
+ const ctx = await generate.getCloudflareContext({ async: true });
122
155
  return ctx?.env;
123
156
  }
124
157
  catch {
@@ -118,7 +118,7 @@ export interface GenerateRoutingConfig {
118
118
  * Cloudflare environment bindings (or getter returning bindings).
119
119
  * Supported in Vinext, Cloudflare Workers, and OpenNext.
120
120
  */
121
- env?: Record<string, unknown> | (() => Record<string, unknown> | Promise<Record<string, unknown>>);
121
+ env?: object | Record<string, unknown> | (() => object | Record<string, unknown> | Promise<object | Record<string, unknown>>);
122
122
  /**
123
123
  * Request execution context (providing `waitUntil`), or a getter returning it.
124
124
  * In Vinext, `getRequestExecutionContext()` from `vinext/shims/request-context` can be passed.
@@ -144,6 +144,19 @@ export interface GenerateRoutingConfig {
144
144
  * Set one of the two getters to scope the banner to GDPR regions only.
145
145
  */
146
146
  getCloudflareContext?: CookieConsentGetCloudflareContext;
147
+ /**
148
+ * Request headers read (in order) by `getCountry()` to resolve the
149
+ * visitor's country. Defaults to `['x-cf-country', 'cf-ipcountry']` —
150
+ * override when your edge/proxy forwards it under a different name.
151
+ * `cookieConsent.countryHeaderNames` takes precedence for the
152
+ * cookie-consent banner's own resolution.
153
+ */
154
+ countryHeaderNames?: readonly string[];
155
+ /**
156
+ * Request headers read (in order) by `getTimezone()` to resolve the
157
+ * visitor's IANA timezone. Defaults to `['x-cf-timezone', 'cf-timezone']`.
158
+ */
159
+ timezoneHeaderNames?: readonly string[];
147
160
  }
148
161
  /**
149
162
  * Flexible input accepted by `getCountry()` and `getTimezone()`:
@@ -359,14 +372,19 @@ export interface CookieConsentRoutingConfig {
359
372
  * over `getCloudflareContext` when both are set.
360
373
  */
361
374
  getCountryCode?: () => string | undefined | Promise<string | undefined>;
375
+ /**
376
+ * Request headers read (in order) to resolve the visitor's country when
377
+ * neither `getCountryCode` nor `generate.getCloudflareContext` produced
378
+ * one. Defaults to `['x-cf-country', 'cf-ipcountry']` — override when
379
+ * your edge/proxy forwards the country under a different name.
380
+ */
381
+ countryHeaderNames?: readonly string[];
362
382
  /**
363
383
  * Country codes (ISO 3166-1 alpha-2) for which the cookie-consent banner
364
- * is required. Only consulted when `getCountryCode` or
365
- * `generate.getCloudflareContext` is set. Defaults to the EU/EEA + UK +
366
- * Switzerland (GDPR/UK-GDPR/nFADP scope). A visitor whose resolved
367
- * country isn't in this set is treated as NOT requiring consent; a
368
- * country that couldn't be resolved still requires it (fail-safe:
369
- * unknown defaults to "ask").
384
+ * is required. Defaults to the EU/EEA + UK + Switzerland
385
+ * (GDPR/UK-GDPR/nFADP scope). A visitor whose resolved country isn't in
386
+ * this set is treated as NOT requiring consent; a country that couldn't
387
+ * be resolved still requires it (fail-safe: unknown defaults to "ask").
370
388
  */
371
389
  gdprCountries?: readonly string[];
372
390
  /**
package/llms.txt CHANGED
@@ -11,7 +11,7 @@ other subpath can be used.
11
11
  - `.` — everything, re-exported (prefer the flat subpaths below for smaller bundles).
12
12
  - `./client` — client-side barrel: `LocaleLink`, `usePathname`, `setCookieClient`, `getCookieClient`.
13
13
  - `./server` — server-side barrel: `getMessage`, `getTranslations`, `getLocale`, `getCountry`, `getTimezone`, `resolveEnv`, `IntlProvider`, `Link`, `IntlHelperScript`, `getLocaleStaticParams`.
14
- - `./geo` / `./getCountry` / `./getTimezone` — country & timezone resolver helpers (`getCountry(input?)`, `getTimezone(input?, fallback?)`, `resolveEnv(generate?)`) for Vinext, OpenNext, and Cloudflare Workers.
14
+ - `./geo` / `./getCountry` / `./getTimezone` — country & timezone resolver helpers (`getCountry(input?, generate?, headerNames?)`, `getTimezone(input?, fallback?, generate?, headerNames?)`, `resolveEnv(generate?)`) for Vinext, OpenNext, and Cloudflare Workers. Custom header names can be configured via `generate.countryHeaderNames`/`generate.timezoneHeaderNames` or passed per-call.
15
15
  - `./middleware` — `intlMiddleware` for `middleware.ts`; locale detection/rewrite/redirect and automatic CF header forwarding.
16
16
  - `./setIntlConfig` — identity function for typed `RoutingConfig` authoring; use in your `@intl-config` file.
17
17
  - `./serverProvider` — `IntlProvider` server component (also under `./server`).
@@ -46,7 +46,7 @@ other subpath can be used.
46
46
  - `./CookieConsentDialog` — default cookie-consent banner; accepts per-slot `classNames`/`styles` or a `render` prop for fully custom markup. When `link` is omitted, renders a default link to `cookieConsent.privacyPolicyPath` (defaults to `'/privacy-policy'`; label via `privacyPolicyLinkText`, default `"Privacy Policy"`) — pass `link={null}` for no link, or set `privacyPolicyPath: false` to disable it everywhere.
47
47
  - `./PrivacyPolicyUpdateDialog` — "privacy policy updated" banner; auto-enabled only when `cookieConsent.privacyPolicyDate` is set. Same default-link behavior as `CookieConsentDialog` (label default `"Learn more"`).
48
48
  - `./cookieConsentAnalytics` — `CookieConsentAnalytics`: gates Cloudflare Web Analytics / Google Ads / Google Analytics / AdSense / Microsoft Clarity behind consent; rendered automatically by `IntlProvider` when `cookieConsent.secrets` or `getSecrets` is set (and `autoWireAnalytics !== false`). Never renders in local dev (`NODE_ENV === 'development'`) unless `cookieConsent.enableAnalyticsInDevMode` is `true`.
49
- - Country-based gating (`cookieConsent.getCountryCode` / `getCloudflareContext` + `gdprCountries`): resolved server-side by `IntlProvider` into a `requiresConsent` boolean passed to `CookieConsentProvider`. Neither getter set gating off, consent always implicitly granted. `getCountryCode` (direct country resolver) takes precedence over `getCloudflareContext` (reads `cf.country`) when both are set. `getCloudflareContext` accepts `@opennextjs/cloudflare`'s `getCloudflareContext` function directly (its exact overloaded signature — `CookieConsentGetCloudflareContext`), called internally with `{ async: true }`. Unresolved country (or a `null` context) always requires consent (fail-safe). `./cookieConsent` also exports `defaultGdprCountries` (EU/EEA + UK + Switzerland).
49
+ - Country-based gating (`cookieConsent.countryHeaderNames` / `getCountryCode` / `getCloudflareContext` + `gdprCountries`): resolved server-side by `IntlProvider` into a `requiresConsent` boolean passed to `CookieConsentProvider`. Automatically falls back to request headers (`x-cf-country`, `cf-ipcountry` or custom `countryHeaderNames`) when neither getter is set or when they resolve empty. `getCountryCode` (direct country resolver) takes precedence over `getCloudflareContext` (reads `cf.country`) when both are set. Unresolved country (or a `null` context) always requires consent (fail-safe). `./cookieConsent` also exports `defaultGdprCountries` (EU/EEA + UK + Switzerland).
50
50
 
51
51
  ## `db*` subpaths (require `db` set on your `RoutingConfig`)
52
52
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudflare-next-intl",
3
- "version": "0.8.39",
3
+ "version": "0.8.41",
4
4
  "description": "Optimized Next Intl Package Special for App Router and Cloudflare",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",