cloudflare-next-intl 0.8.38 → 0.8.40

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
@@ -188,6 +188,55 @@ import { getLocale } from "cloudflare-next-intl/server";
188
188
  const locale = await getLocale();
189
189
  ```
190
190
 
191
+ ### Geo & Timezone Resolution (Vinext & Cloudflare)
192
+
193
+ `cloudflare-next-intl` provides built-in country and timezone resolution methods that work seamlessly in Server Components, Server Actions, Route Handlers, and Middleware under Vinext and OpenNext:
194
+
195
+ ```tsx
196
+ import { getCountry, getTimezone } from "cloudflare-next-intl/server";
197
+ // Or: import { getCountry, getTimezone } from "cloudflare-next-intl/geo";
198
+
199
+ export default async function Page() {
200
+ const country = await getCountry(); // e.g. "US", "DE", "UA"
201
+ const timezone = await getTimezone(undefined, "UTC"); // e.g. "America/New_York", "Europe/Kyiv"
202
+
203
+ return <p>Visitor Country: {country}, Timezone: {timezone}</p>;
204
+ }
205
+ ```
206
+
207
+ In middleware or custom handlers, you can pass the `request` or `headers` directly:
208
+
209
+ ```typescript
210
+ import { getCountry, getTimezone } from "cloudflare-next-intl/server";
211
+
212
+ export function customHandler(request: NextRequest) {
213
+ const country = getCountry(request);
214
+ const timezone = getTimezone(request, "UTC");
215
+ }
216
+ ```
217
+
218
+ `intlMiddleware` automatically forwards `x-cf-country` and `x-cf-timezone` headers from `request.cf` so they are immediately available downstream via `next/headers`.
219
+
220
+ ### Vinext Runtime Configuration
221
+
222
+ When deploying under [Vinext](https://github.com/cloudflare/vinext) with `cloudflare:workers`, you can pass your `env` and execution context (`ctx`) directly in `setIntlConfig`:
223
+
224
+ ```typescript
225
+ // src/i18n/intl_config.ts
226
+ import { setIntlConfig } from "cloudflare-next-intl/setIntlConfig";
227
+ import { env } from "cloudflare:workers";
228
+ import { getRequestExecutionContext } from "vinext/shims/request-context";
229
+
230
+ export default setIntlConfig({
231
+ locales: ["en", "de"],
232
+ defaultLocale: "en",
233
+ generate: {
234
+ env,
235
+ ctx: () => getRequestExecutionContext() ?? undefined,
236
+ },
237
+ });
238
+ ```
239
+
191
240
  ```tsx
192
241
  // Client Components ("use client")
193
242
  import { useLocale } from "cloudflare-next-intl/use";
@@ -130,6 +130,16 @@ export default async function intlMiddleware(request, options) {
130
130
  response.headers.set('Content-Language', effectiveLocaleForRequest);
131
131
  response.headers.set('x-pathname', pathWithoutLocale);
132
132
  response.headers.set('x-search', search);
133
+ const country = request.cf?.country ?? request.headers.get('cf-ipcountry') ?? request.headers.get('x-cf-country');
134
+ if (country) {
135
+ request.headers.set('x-cf-country', country);
136
+ response.headers.set('x-cf-country', country);
137
+ }
138
+ const timezone = request.cf?.timezone ?? request.headers.get('cf-timezone') ?? request.headers.get('x-cf-timezone');
139
+ if (timezone) {
140
+ request.headers.set('x-cf-timezone', timezone);
141
+ response.headers.set('x-cf-timezone', timezone);
142
+ }
133
143
  // Auto-wires the firebase_auth submodule's redirect/session-refresh
134
144
  // logic when `firebaseAuth` is configured — dynamic import so this
135
145
  // file never pulls in firebase_auth/** (and transitively firebase/*)
@@ -5,14 +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
- /**
9
- * Resolves whether the cookie-consent banner is required for a visitor.
10
- *
11
- * - Neither getter set: fail-safe — consent is required by default since
12
- * the visitor's country can't be determined at all.
13
- * - Either getter set: fail-safe — a country that couldn't be resolved
14
- * still requires consent; only a resolved country OUTSIDE
15
- * `gdprCountries` skips the banner. `getCountryCode` takes precedence
16
- * over `getCloudflareContext` when both are set.
17
- */
18
8
  export default function resolveRequiresConsent(getCountryCode: (() => string | undefined | Promise<string | undefined>) | undefined, getCloudflareContext: CookieConsentGetCloudflareContext | undefined, gdprCountries: readonly string[] | undefined, errorHandlingConfig?: ErrorHandlingRoutingConfig): Promise<boolean>;
@@ -31,16 +31,6 @@ function getGdprCountriesSet(gdprCountries) {
31
31
  }
32
32
  return set;
33
33
  }
34
- /**
35
- * Resolves whether the cookie-consent banner is required for a visitor.
36
- *
37
- * - Neither getter set: fail-safe — consent is required by default since
38
- * the visitor's country can't be determined at all.
39
- * - Either getter set: fail-safe — a country that couldn't be resolved
40
- * still requires consent; only a resolved country OUTSIDE
41
- * `gdprCountries` skips the banner. `getCountryCode` takes precedence
42
- * over `getCloudflareContext` when both are set.
43
- */
44
34
  export default async function resolveRequiresConsent(getCountryCode, getCloudflareContext, gdprCountries, errorHandlingConfig) {
45
35
  if (!getCountryCode && !getCloudflareContext)
46
36
  return true;
@@ -1,6 +1,7 @@
1
1
  import reportError from '../error_handling/report_error';
2
2
  import requireDbConfig from './require_config';
3
3
  import resolveConfigValue from './resolve_config_value';
4
+ import { resolveEnv } from '../server/functions/geo';
4
5
  let pgModule;
5
6
  /**
6
7
  * Loads `pg` lazily, so an app that never touches the Postgres transport never
@@ -11,13 +12,18 @@ function loadPg() {
11
12
  pgModule ?? (pgModule = import('pg'));
12
13
  return pgModule;
13
14
  }
14
- async function resolveConnectionString(db) {
15
+ async function resolveConnectionString(db, generate) {
15
16
  const configured = await resolveConfigValue(db.connectionString);
16
17
  if (configured)
17
18
  return configured;
19
+ const env = await resolveEnv(generate);
20
+ const hyperdriveConn = env?.HYPERDRIVE?.connectionString;
21
+ if (hyperdriveConn && hyperdriveConn !== 'postgresql://user:pass@localhost:5432/db') {
22
+ return hyperdriveConn;
23
+ }
18
24
  throw new Error('db: could not resolve a Postgres connection string. Set `db.connectionString` ' +
19
25
  'to a connection string, or to a function returning one (e.g. reading a ' +
20
- 'Hyperdrive binding off `getCloudflareContext().env`).');
26
+ 'Hyperdrive binding off `env` or `getCloudflareContext().env`).');
21
27
  }
22
28
  /**
23
29
  * Runs `queryFn` on a Postgres client scoped to this single call: one
@@ -29,7 +35,7 @@ async function resolveConnectionString(db) {
29
35
  export async function withDbClient(config, queryFn) {
30
36
  const db = config.db;
31
37
  requireDbConfig(db);
32
- const connectionString = await resolveConnectionString(db);
38
+ const connectionString = await resolveConnectionString(db, config.generate);
33
39
  const { Client: PgClient } = await loadPg();
34
40
  const client = new PgClient({ connectionString });
35
41
  let result;
@@ -50,23 +56,26 @@ export async function withDbClient(config, queryFn) {
50
56
  }
51
57
  finally {
52
58
  const endPromise = connected ? client.end() : Promise.resolve();
53
- const getContext = config.generate?.getCloudflareContext;
54
- if (!getContext || db.disconnectAfterRequest === false) {
55
- await endPromise.catch(() => undefined);
56
- }
57
- else {
58
- try {
59
- const context = await getContext({ async: true });
60
- if (typeof context?.ctx?.waitUntil === 'function') {
61
- context.ctx.waitUntil(endPromise.catch(() => undefined));
59
+ let ctx;
60
+ if (db.disconnectAfterRequest !== false) {
61
+ if (config.generate?.ctx) {
62
+ ctx = typeof config.generate.ctx === 'function' ? config.generate.ctx() : config.generate.ctx;
63
+ }
64
+ else if (config.generate?.getCloudflareContext) {
65
+ try {
66
+ const context = await config.generate.getCloudflareContext({ async: true });
67
+ ctx = context?.ctx;
62
68
  }
63
- else {
64
- await endPromise.catch(() => undefined);
69
+ catch {
70
+ // Ignore context resolution errors
65
71
  }
66
72
  }
67
- catch {
68
- await endPromise.catch(() => undefined);
69
- }
73
+ }
74
+ if (ctx && typeof ctx.waitUntil === 'function') {
75
+ ctx.waitUntil(endPromise.catch(() => undefined));
76
+ }
77
+ else {
78
+ await endPromise.catch(() => undefined);
70
79
  }
71
80
  }
72
81
  return result;
@@ -99,7 +108,7 @@ export async function withSessionLock(fn) {
99
108
  export async function connectToPostgres(config) {
100
109
  const db = config.db;
101
110
  requireDbConfig(db);
102
- const connectionString = await resolveConnectionString(db);
111
+ const connectionString = await resolveConnectionString(db, config.generate);
103
112
  const { Client: PgClient } = await loadPg();
104
113
  const client = new PgClient({ connectionString });
105
114
  client.on('error', (error) => {
@@ -127,11 +127,21 @@ export default async function reportError(config, params) {
127
127
  lastDedupKey = dedupKey;
128
128
  lastReportedAt = now;
129
129
  }
130
- // `getCloudflareContext` only exists server-side inside a Cloudflare
131
- // Worker (with `initOpenNextCloudflareForDev` set up in dev) — calling
132
- // it at all for a client-originated report throws synchronously, before
133
- // any "is it available" check can run.
134
- const ctx = params.isClient ? undefined : config?.generate?.getCloudflareContext?.({ async: false })?.ctx;
130
+ let ctx;
131
+ if (!params.isClient) {
132
+ const generate = config?.generate;
133
+ if (generate?.ctx) {
134
+ ctx = typeof generate.ctx === 'function' ? generate.ctx() : generate.ctx;
135
+ }
136
+ else if (generate?.getCloudflareContext) {
137
+ try {
138
+ ctx = generate.getCloudflareContext({ async: false })?.ctx;
139
+ }
140
+ catch {
141
+ // Ignore context errors
142
+ }
143
+ }
144
+ }
135
145
  if (ctx?.waitUntil) {
136
146
  ctx.waitUntil(callOnError(errorHandling, params));
137
147
  return;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,14 @@
1
+ import { bench, describe } from 'vitest';
2
+ import { getCountry, getTimezone } from './geo';
3
+ describe('geo benchmarks', () => {
4
+ const headers = new Headers({
5
+ 'x-cf-country': 'UA',
6
+ 'x-cf-timezone': 'Europe/Kyiv',
7
+ });
8
+ bench('getCountry with Headers', async () => {
9
+ await getCountry(headers);
10
+ });
11
+ bench('getTimezone with Headers', async () => {
12
+ await getTimezone(headers);
13
+ });
14
+ });
@@ -0,0 +1,25 @@
1
+ import type { GenerateRoutingConfig, RequestOrHeaders } from '../../types/types';
2
+ /**
3
+ * Resolves the client's ISO 3166-1 alpha-2 country code (e.g. "US", "DE", "UA").
4
+ *
5
+ * Checks in order:
6
+ * 1. Explicit `input` (Request, NextRequest, or Headers) if provided
7
+ * 2. Next.js request headers via `headers()` (`x-cf-country`, `cf-ipcountry`)
8
+ * 3. `generate.getCloudflareContext` or `cf.country` if passed
9
+ * 4. `undefined` if outside request scope or unavailable
10
+ */
11
+ export declare function getCountry(input?: RequestOrHeaders, generate?: GenerateRoutingConfig): Promise<string | undefined>;
12
+ /**
13
+ * Resolves the client's IANA timezone string (e.g. "America/New_York", "Europe/Kyiv", "UTC").
14
+ *
15
+ * Checks in order:
16
+ * 1. Explicit `input` (Request, NextRequest, or Headers) if provided
17
+ * 2. Next.js request headers via `headers()` (`x-cf-timezone`, `cf-timezone`)
18
+ * 3. `generate.getCloudflareContext` or `cf.timezone` if passed
19
+ * 4. `fallback` (or `undefined`) if outside request scope or unavailable
20
+ */
21
+ export declare function getTimezone(input?: RequestOrHeaders, fallback?: string, generate?: GenerateRoutingConfig): Promise<string | undefined>;
22
+ /**
23
+ * Resolves the Cloudflare environment bindings object from `generate.env` or `generate.getCloudflareContext`.
24
+ */
25
+ export declare function resolveEnv(generate?: GenerateRoutingConfig): Promise<Record<string, unknown> | undefined>;
@@ -0,0 +1,128 @@
1
+ function extractHeader(h, name) {
2
+ if (typeof h.get === 'function') {
3
+ const val = h.get(name);
4
+ return val ?? undefined;
5
+ }
6
+ const rec = h;
7
+ const val = rec[name] ?? rec[name.toLowerCase()];
8
+ return (typeof val === 'string' && val.length > 0) ? val : undefined;
9
+ }
10
+ /**
11
+ * Resolves the client's ISO 3166-1 alpha-2 country code (e.g. "US", "DE", "UA").
12
+ *
13
+ * Checks in order:
14
+ * 1. Explicit `input` (Request, NextRequest, or Headers) if provided
15
+ * 2. Next.js request headers via `headers()` (`x-cf-country`, `cf-ipcountry`)
16
+ * 3. `generate.getCloudflareContext` or `cf.country` if passed
17
+ * 4. `undefined` if outside request scope or unavailable
18
+ */
19
+ export async function getCountry(input, generate) {
20
+ if (input) {
21
+ if ('headers' in input && input.headers) {
22
+ const country = extractHeader(input.headers, 'x-cf-country') ?? extractHeader(input.headers, 'cf-ipcountry');
23
+ if (country)
24
+ return country;
25
+ }
26
+ else if (typeof input.get === 'function') {
27
+ const country = input.get('x-cf-country') ?? input.get('cf-ipcountry') ?? undefined;
28
+ if (country)
29
+ return country;
30
+ }
31
+ const cf = input.cf;
32
+ if (cf?.country && typeof cf.country === 'string' && cf.country.length > 0) {
33
+ return cf.country;
34
+ }
35
+ }
36
+ try {
37
+ const { headers } = await import('next/headers');
38
+ const h = await headers();
39
+ const country = h.get('x-cf-country') ?? h.get('cf-ipcountry') ?? undefined;
40
+ if (country)
41
+ return country;
42
+ }
43
+ catch {
44
+ // Outside request scope / build time
45
+ }
46
+ if (generate?.getCloudflareContext) {
47
+ try {
48
+ const ctx = await generate.getCloudflareContext({ async: true });
49
+ if (ctx?.cf?.country && typeof ctx.cf.country === 'string' && ctx.cf.country.length > 0) {
50
+ return ctx.cf.country;
51
+ }
52
+ }
53
+ catch {
54
+ // Ignore context resolution errors
55
+ }
56
+ }
57
+ return undefined;
58
+ }
59
+ /**
60
+ * Resolves the client's IANA timezone string (e.g. "America/New_York", "Europe/Kyiv", "UTC").
61
+ *
62
+ * Checks in order:
63
+ * 1. Explicit `input` (Request, NextRequest, or Headers) if provided
64
+ * 2. Next.js request headers via `headers()` (`x-cf-timezone`, `cf-timezone`)
65
+ * 3. `generate.getCloudflareContext` or `cf.timezone` if passed
66
+ * 4. `fallback` (or `undefined`) if outside request scope or unavailable
67
+ */
68
+ export async function getTimezone(input, fallback, generate) {
69
+ if (input) {
70
+ if ('headers' in input && input.headers) {
71
+ const tz = extractHeader(input.headers, 'x-cf-timezone') ?? extractHeader(input.headers, 'cf-timezone');
72
+ if (tz)
73
+ return tz;
74
+ }
75
+ else if (typeof input.get === 'function') {
76
+ const tz = input.get('x-cf-timezone') ?? input.get('cf-timezone') ?? undefined;
77
+ if (tz)
78
+ return tz;
79
+ }
80
+ const cf = input.cf;
81
+ if (cf?.timezone && typeof cf.timezone === 'string' && cf.timezone.length > 0) {
82
+ return cf.timezone;
83
+ }
84
+ }
85
+ try {
86
+ const { headers } = await import('next/headers');
87
+ const h = await headers();
88
+ const tz = h.get('x-cf-timezone') ?? h.get('cf-timezone') ?? undefined;
89
+ if (tz)
90
+ return tz;
91
+ }
92
+ catch {
93
+ // Outside request scope
94
+ }
95
+ if (generate?.getCloudflareContext) {
96
+ try {
97
+ const ctx = await generate.getCloudflareContext({ async: true });
98
+ if (ctx?.cf?.timezone && typeof ctx.cf.timezone === 'string' && ctx.cf.timezone.length > 0) {
99
+ return ctx.cf.timezone;
100
+ }
101
+ }
102
+ catch {
103
+ // Ignore context resolution errors
104
+ }
105
+ }
106
+ return fallback;
107
+ }
108
+ /**
109
+ * Resolves the Cloudflare environment bindings object from `generate.env` or `generate.getCloudflareContext`.
110
+ */
111
+ export async function resolveEnv(generate) {
112
+ if (!generate)
113
+ return undefined;
114
+ if (generate.env) {
115
+ const resolved = typeof generate.env === 'function' ? await generate.env() : generate.env;
116
+ return resolved;
117
+ }
118
+ if (generate.getCloudflareContext) {
119
+ try {
120
+ const ctx = await generate.getCloudflareContext({ async: true });
121
+ return ctx?.env;
122
+ }
123
+ catch {
124
+ return undefined;
125
+ }
126
+ }
127
+ return undefined;
128
+ }
@@ -3,3 +3,4 @@ export { default as IntlProvider } from './components/server_provider';
3
3
  export { default as Link } from './components/link';
4
4
  export { default as IntlHelperScript } from './components/helper_script';
5
5
  export { getLocaleStaticParams } from './functions/locale_static_params';
6
+ export { getCountry, getTimezone, resolveEnv } from './functions/geo';
@@ -7,3 +7,4 @@ export { default as IntlProvider } from './components/server_provider';
7
7
  export { default as Link } from './components/link';
8
8
  export { default as IntlHelperScript } from './components/helper_script';
9
9
  export { getLocaleStaticParams } from './functions/locale_static_params';
10
+ export { getCountry, getTimezone, resolveEnv } from './functions/geo';
@@ -114,6 +114,20 @@ export interface RoutingConfig<AppLocales extends Locales, AppLocalePrefixMode e
114
114
  errorHandling?: ErrorHandlingRoutingConfig;
115
115
  }
116
116
  export interface GenerateRoutingConfig {
117
+ /**
118
+ * Cloudflare environment bindings (or getter returning bindings).
119
+ * Supported in Vinext, Cloudflare Workers, and OpenNext.
120
+ */
121
+ env?: object | Record<string, unknown> | (() => object | Record<string, unknown> | Promise<object | Record<string, unknown>>);
122
+ /**
123
+ * Request execution context (providing `waitUntil`), or a getter returning it.
124
+ * In Vinext, `getRequestExecutionContext()` from `vinext/shims/request-context` can be passed.
125
+ */
126
+ ctx?: {
127
+ waitUntil?: (promise: Promise<unknown>) => void;
128
+ } | (() => {
129
+ waitUntil?: (promise: Promise<unknown>) => void;
130
+ } | undefined);
117
131
  /**
118
132
  * Pass `getCloudflareContext` from `@opennextjs/cloudflare` directly
119
133
  * (not a dependency of this package, so bring your own import) — its
@@ -131,6 +145,18 @@ export interface GenerateRoutingConfig {
131
145
  */
132
146
  getCloudflareContext?: CookieConsentGetCloudflareContext;
133
147
  }
148
+ /**
149
+ * Flexible input accepted by `getCountry()` and `getTimezone()`:
150
+ * A `Request`, `NextRequest`, `Headers` instance, or an object containing `headers` and/or `cf`.
151
+ */
152
+ export type RequestOrHeaders = Request | Headers | {
153
+ headers?: Headers | Record<string, string | null | undefined>;
154
+ cf?: {
155
+ country?: string;
156
+ timezone?: string;
157
+ [key: string]: unknown;
158
+ };
159
+ } | undefined;
134
160
  export interface ErrorHandlingParams {
135
161
  /** The caught error, in whatever shape it was thrown/rejected with. */
136
162
  error: unknown;
package/llms.txt CHANGED
@@ -10,8 +10,9 @@ other subpath can be used.
10
10
 
11
11
  - `.` — everything, re-exported (prefer the flat subpaths below for smaller bundles).
12
12
  - `./client` — client-side barrel: `LocaleLink`, `usePathname`, `setCookieClient`, `getCookieClient`.
13
- - `./server` — server-side barrel: `getMessage`, `getTranslations`, `getLocale`, `IntlProvider`, `Link`, `IntlHelperScript`, `getLocaleStaticParams`.
14
- - `./middleware` — `intlMiddleware` for `middleware.ts`; locale detection/rewrite/redirect.
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.
15
+ - `./middleware` — `intlMiddleware` for `middleware.ts`; locale detection/rewrite/redirect and automatic CF header forwarding.
15
16
  - `./setIntlConfig` — identity function for typed `RoutingConfig` authoring; use in your `@intl-config` file.
16
17
  - `./serverProvider` — `IntlProvider` server component (also under `./server`).
17
18
  - `./Link` — server-side locale-aware `<Link>` (also under `./server`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudflare-next-intl",
3
- "version": "0.8.38",
3
+ "version": "0.8.40",
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",
@@ -30,6 +30,18 @@
30
30
  "types": "./dist/src/server/index.d.ts",
31
31
  "import": "./dist/src/server/index.js"
32
32
  },
33
+ "./geo": {
34
+ "types": "./dist/src/server/functions/geo.d.ts",
35
+ "import": "./dist/src/server/functions/geo.js"
36
+ },
37
+ "./getCountry": {
38
+ "types": "./dist/src/server/functions/geo.d.ts",
39
+ "import": "./dist/src/server/functions/geo.js"
40
+ },
41
+ "./getTimezone": {
42
+ "types": "./dist/src/server/functions/geo.d.ts",
43
+ "import": "./dist/src/server/functions/geo.js"
44
+ },
33
45
  "./middleware": {
34
46
  "types": "./dist/src/config/middleware.d.ts",
35
47
  "import": "./dist/src/config/middleware.js"