cloudflare-next-intl 0.8.37 → 0.8.39
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 +49 -0
- package/dist/src/config/middleware.js +10 -0
- package/dist/src/cookie_consent/gdpr_countries.d.ts +0 -10
- package/dist/src/cookie_consent/gdpr_countries.js +0 -10
- package/dist/src/db/connection.js +27 -18
- package/dist/src/error_handling/report_error.js +15 -5
- package/dist/src/firebase_auth/middleware/update_session.js +27 -6
- package/dist/src/server/functions/geo.bench.d.ts +1 -0
- package/dist/src/server/functions/geo.bench.js +14 -0
- package/dist/src/server/functions/geo.d.ts +25 -0
- package/dist/src/server/functions/geo.js +129 -0
- package/dist/src/server/index.d.ts +1 -0
- package/dist/src/server/index.js +1 -0
- package/dist/src/types/types.d.ts +26 -0
- package/llms.txt +3 -2
- package/package.json +13 -1
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
|
-
|
|
54
|
-
if (
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
69
|
+
catch {
|
|
70
|
+
// Ignore context resolution errors
|
|
65
71
|
}
|
|
66
72
|
}
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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;
|
|
@@ -225,6 +225,7 @@ export default async function updateSession(request, baseResponse, locale, rebui
|
|
|
225
225
|
if (rawPath.startsWith('/_next') || /\.[a-zA-Z0-9]+$/.test(lastSegment)) {
|
|
226
226
|
return baseResponse;
|
|
227
227
|
}
|
|
228
|
+
const isPrefetch = isPrefetchRequest(request);
|
|
228
229
|
const localePrefix = locale === config.defaultLocale ? '' : requestPrefix;
|
|
229
230
|
const localeUrl = (target) => new URL(withRedirectQuery(`${localePrefix}${target === '/' ? '' : target}` || '/', request.nextUrl.search), request.url);
|
|
230
231
|
// Emailed Firebase action links all arrive on the single project-wide
|
|
@@ -283,7 +284,7 @@ export default async function updateSession(request, baseResponse, locale, rebui
|
|
|
283
284
|
}
|
|
284
285
|
}
|
|
285
286
|
parsed.search = request.nextUrl.search;
|
|
286
|
-
return buildRedirect(baseResponse, parsed);
|
|
287
|
+
return buildRedirect(baseResponse, parsed, isPrefetch);
|
|
287
288
|
}
|
|
288
289
|
}
|
|
289
290
|
catch {
|
|
@@ -308,7 +309,7 @@ export default async function updateSession(request, baseResponse, locale, rebui
|
|
|
308
309
|
url.searchParams.delete(key);
|
|
309
310
|
}
|
|
310
311
|
}
|
|
311
|
-
return buildRedirect(baseResponse, url);
|
|
312
|
+
return buildRedirect(baseResponse, url, isPrefetch);
|
|
312
313
|
}
|
|
313
314
|
}
|
|
314
315
|
}
|
|
@@ -444,14 +445,14 @@ export default async function updateSession(request, baseResponse, locale, rebui
|
|
|
444
445
|
response = baseResponse;
|
|
445
446
|
}
|
|
446
447
|
else if (!hasSession || clearInvalidSession) {
|
|
447
|
-
response = isAuthPage ? baseResponse : buildRedirect(baseResponse, localeUrl(fa.redirectAuthPath));
|
|
448
|
+
response = isAuthPage ? baseResponse : buildRedirect(baseResponse, localeUrl(fa.redirectAuthPath), isPrefetch);
|
|
448
449
|
}
|
|
449
450
|
else if (unverifiedEmail) {
|
|
450
451
|
// Checked before the auth-page redirect: an unverified signed-in
|
|
451
452
|
// user must land on verifyEmailPath even if they navigated to an
|
|
452
453
|
// auth page like /login — homePath is not a state they're allowed
|
|
453
454
|
// to reach yet either.
|
|
454
|
-
response = buildRedirect(baseResponse, localeUrl(fa.verifyEmailPath));
|
|
455
|
+
response = buildRedirect(baseResponse, localeUrl(fa.verifyEmailPath), isPrefetch);
|
|
455
456
|
}
|
|
456
457
|
else if (isAuthPage || (isVerifyEmailPage && decodeJwtPayload(token)?.email_verified === true)) {
|
|
457
458
|
// A verified user has no reason to be on verifyEmailPath either —
|
|
@@ -468,7 +469,7 @@ export default async function updateSession(request, baseResponse, locale, rebui
|
|
|
468
469
|
// boolean `user.emailVerified`. The two disagreeing caused an
|
|
469
470
|
// infinite client<->server redirect loop on this exact page when a
|
|
470
471
|
// token's claim was merely absent rather than `false`.
|
|
471
|
-
response = buildRedirect(baseResponse, localeUrl(fa.homePath));
|
|
472
|
+
response = buildRedirect(baseResponse, localeUrl(fa.homePath), isPrefetch);
|
|
472
473
|
}
|
|
473
474
|
else {
|
|
474
475
|
response = baseResponse;
|
|
@@ -499,7 +500,27 @@ export default async function updateSession(request, baseResponse, locale, rebui
|
|
|
499
500
|
return response;
|
|
500
501
|
}
|
|
501
502
|
/** A redirect response can't carry forward `baseResponse`'s rewrite/next decision, so this copies its cookies/headers across instead of dropping them. */
|
|
502
|
-
|
|
503
|
+
// A router prefetch must never be answered with a redirect. Next's segment
|
|
504
|
+
// cache stores the entry under the REQUESTED url while `fetch` transparently
|
|
505
|
+
// follows the 3xx, so the entry it caches describes a different route than the
|
|
506
|
+
// key it is filed under; the router then keeps re-requesting it, which
|
|
507
|
+
// re-redirects, an unbounded prefetch loop that hammers the origin (every
|
|
508
|
+
// signed-out page carrying a `<Link>` to a guarded route reproduced it).
|
|
509
|
+
// An empty 204 is treated as an un-cacheable prefetch miss instead: the router
|
|
510
|
+
// backs off, and the guard still runs in full on the real navigation, which
|
|
511
|
+
// is never a prefetch.
|
|
512
|
+
function isPrefetchRequest(request) {
|
|
513
|
+
return request.headers.get('next-router-prefetch') === '1'
|
|
514
|
+
|| request.headers.get('purpose') === 'prefetch'
|
|
515
|
+
|| request.headers.get('x-purpose') === 'prefetch';
|
|
516
|
+
}
|
|
517
|
+
function buildRedirect(baseResponse, url, isPrefetch = false) {
|
|
518
|
+
if (isPrefetch) {
|
|
519
|
+
return new NextResponse(null, {
|
|
520
|
+
status: 204,
|
|
521
|
+
headers: { 'Cache-Control': 'private, no-cache, no-store, max-age=0, must-revalidate' },
|
|
522
|
+
});
|
|
523
|
+
}
|
|
503
524
|
const redirectResponse = NextResponse.redirect(url);
|
|
504
525
|
baseResponse.cookies.getAll().forEach((cookie) => redirectResponse.cookies.set(cookie));
|
|
505
526
|
baseResponse.headers.forEach((value, key) => redirectResponse.headers.set(key, value));
|
|
@@ -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. `config.generate.getCloudflareContext` or `cf.country` if configured
|
|
9
|
+
* 4. `undefined` if outside request scope or unavailable
|
|
10
|
+
*/
|
|
11
|
+
export declare function getCountry(input?: RequestOrHeaders): 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. `config.generate.getCloudflareContext` or `cf.timezone` if configured
|
|
19
|
+
* 4. `fallback` (or `undefined`) if outside request scope or unavailable
|
|
20
|
+
*/
|
|
21
|
+
export declare function getTimezone(input?: RequestOrHeaders, fallback?: string): 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,129 @@
|
|
|
1
|
+
import config from '../../config/intl_config';
|
|
2
|
+
function extractHeader(h, name) {
|
|
3
|
+
if (typeof h.get === 'function') {
|
|
4
|
+
const val = h.get(name);
|
|
5
|
+
return val ?? undefined;
|
|
6
|
+
}
|
|
7
|
+
const rec = h;
|
|
8
|
+
const val = rec[name] ?? rec[name.toLowerCase()];
|
|
9
|
+
return (typeof val === 'string' && val.length > 0) ? val : undefined;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Resolves the client's ISO 3166-1 alpha-2 country code (e.g. "US", "DE", "UA").
|
|
13
|
+
*
|
|
14
|
+
* Checks in order:
|
|
15
|
+
* 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
|
|
18
|
+
* 4. `undefined` if outside request scope or unavailable
|
|
19
|
+
*/
|
|
20
|
+
export async function getCountry(input) {
|
|
21
|
+
if (input) {
|
|
22
|
+
if ('headers' in input && input.headers) {
|
|
23
|
+
const country = extractHeader(input.headers, 'x-cf-country') ?? extractHeader(input.headers, 'cf-ipcountry');
|
|
24
|
+
if (country)
|
|
25
|
+
return country;
|
|
26
|
+
}
|
|
27
|
+
else if (typeof input.get === 'function') {
|
|
28
|
+
const country = input.get('x-cf-country') ?? input.get('cf-ipcountry') ?? undefined;
|
|
29
|
+
if (country)
|
|
30
|
+
return country;
|
|
31
|
+
}
|
|
32
|
+
const cf = input.cf;
|
|
33
|
+
if (cf?.country && typeof cf.country === 'string' && cf.country.length > 0) {
|
|
34
|
+
return cf.country;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const { headers } = await import('next/headers');
|
|
39
|
+
const h = await headers();
|
|
40
|
+
const country = h.get('x-cf-country') ?? h.get('cf-ipcountry') ?? undefined;
|
|
41
|
+
if (country)
|
|
42
|
+
return country;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Outside request scope / build time
|
|
46
|
+
}
|
|
47
|
+
if (config?.generate?.getCloudflareContext) {
|
|
48
|
+
try {
|
|
49
|
+
const ctx = await config.generate.getCloudflareContext({ async: true });
|
|
50
|
+
if (ctx?.cf?.country && typeof ctx.cf.country === 'string' && ctx.cf.country.length > 0) {
|
|
51
|
+
return ctx.cf.country;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Ignore context resolution errors
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Resolves the client's IANA timezone string (e.g. "America/New_York", "Europe/Kyiv", "UTC").
|
|
62
|
+
*
|
|
63
|
+
* Checks in order:
|
|
64
|
+
* 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
|
|
67
|
+
* 4. `fallback` (or `undefined`) if outside request scope or unavailable
|
|
68
|
+
*/
|
|
69
|
+
export async function getTimezone(input, fallback) {
|
|
70
|
+
if (input) {
|
|
71
|
+
if ('headers' in input && input.headers) {
|
|
72
|
+
const tz = extractHeader(input.headers, 'x-cf-timezone') ?? extractHeader(input.headers, 'cf-timezone');
|
|
73
|
+
if (tz)
|
|
74
|
+
return tz;
|
|
75
|
+
}
|
|
76
|
+
else if (typeof input.get === 'function') {
|
|
77
|
+
const tz = input.get('x-cf-timezone') ?? input.get('cf-timezone') ?? undefined;
|
|
78
|
+
if (tz)
|
|
79
|
+
return tz;
|
|
80
|
+
}
|
|
81
|
+
const cf = input.cf;
|
|
82
|
+
if (cf?.timezone && typeof cf.timezone === 'string' && cf.timezone.length > 0) {
|
|
83
|
+
return cf.timezone;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const { headers } = await import('next/headers');
|
|
88
|
+
const h = await headers();
|
|
89
|
+
const tz = h.get('x-cf-timezone') ?? h.get('cf-timezone') ?? undefined;
|
|
90
|
+
if (tz)
|
|
91
|
+
return tz;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Outside request scope
|
|
95
|
+
}
|
|
96
|
+
if (config?.generate?.getCloudflareContext) {
|
|
97
|
+
try {
|
|
98
|
+
const ctx = await config.generate.getCloudflareContext({ async: true });
|
|
99
|
+
if (ctx?.cf?.timezone && typeof ctx.cf.timezone === 'string' && ctx.cf.timezone.length > 0) {
|
|
100
|
+
return ctx.cf.timezone;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// Ignore context resolution errors
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return fallback;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Resolves the Cloudflare environment bindings object from `generate.env` or `generate.getCloudflareContext`.
|
|
111
|
+
*/
|
|
112
|
+
export async function resolveEnv(generate) {
|
|
113
|
+
const gen = generate ?? config?.generate;
|
|
114
|
+
if (!gen)
|
|
115
|
+
return undefined;
|
|
116
|
+
if (gen.env) {
|
|
117
|
+
return typeof gen.env === 'function' ? await gen.env() : gen.env;
|
|
118
|
+
}
|
|
119
|
+
if (gen.getCloudflareContext) {
|
|
120
|
+
try {
|
|
121
|
+
const ctx = await gen.getCloudflareContext({ async: true });
|
|
122
|
+
return ctx?.env;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
@@ -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';
|
package/dist/src/server/index.js
CHANGED
|
@@ -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?: Record<string, unknown> | (() => Record<string, unknown> | Promise<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
|
-
- `./
|
|
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.
|
|
3
|
+
"version": "0.8.39",
|
|
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"
|