@od-oneapp/internationalization 2026.1.1301
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 +164 -0
- package/dist/client-next.d.mts +4 -0
- package/dist/client-next.mjs +4 -0
- package/dist/client.d.mts +24 -0
- package/dist/client.d.mts.map +1 -0
- package/dist/client.mjs +16 -0
- package/dist/client.mjs.map +1 -0
- package/dist/en-BG_-Eo3m.mjs +192 -0
- package/dist/en-BG_-Eo3m.mjs.map +1 -0
- package/dist/middleware-DgXJ2JFB.mjs +10 -0
- package/dist/middleware-DgXJ2JFB.mjs.map +1 -0
- package/dist/middleware.d.mts +10 -0
- package/dist/middleware.d.mts.map +1 -0
- package/dist/middleware.mjs +17 -0
- package/dist/middleware.mjs.map +1 -0
- package/dist/navigation-BCo6Vugt.mjs +39 -0
- package/dist/navigation-BCo6Vugt.mjs.map +1 -0
- package/dist/navigation-BSuDX6-H.d.mts +20 -0
- package/dist/navigation-BSuDX6-H.d.mts.map +1 -0
- package/dist/navigation-Di2SkyuD.mjs +20 -0
- package/dist/navigation-Di2SkyuD.mjs.map +1 -0
- package/dist/next-intl-server-CnBEO3Z3.mjs +25 -0
- package/dist/next-intl-server-CnBEO3Z3.mjs.map +1 -0
- package/dist/react19-cache-DHL04P0L.mjs +485 -0
- package/dist/react19-cache-DHL04P0L.mjs.map +1 -0
- package/dist/request.d.mts +5 -0
- package/dist/request.d.mts.map +1 -0
- package/dist/request.mjs +114 -0
- package/dist/request.mjs.map +1 -0
- package/dist/routing-CK71AHzC.mjs +80 -0
- package/dist/routing-CK71AHzC.mjs.map +1 -0
- package/dist/routing-Cqn-FuJK.mjs +9 -0
- package/dist/routing-Cqn-FuJK.mjs.map +1 -0
- package/dist/routing.d.mts +13 -0
- package/dist/routing.d.mts.map +1 -0
- package/dist/routing.mjs +3 -0
- package/dist/server-CiAHG84s.d.mts +167 -0
- package/dist/server-CiAHG84s.d.mts.map +1 -0
- package/dist/server-next.d.mts +37 -0
- package/dist/server-next.d.mts.map +1 -0
- package/dist/server-next.mjs +52 -0
- package/dist/server-next.mjs.map +1 -0
- package/dist/server.d.mts +3 -0
- package/dist/server.mjs +12 -0
- package/dist/server.mjs.map +1 -0
- package/package.json +104 -0
- package/src/__tests__/client.test.ts +37 -0
- package/src/__tests__/dictionary-loader.test.ts +228 -0
- package/src/__tests__/exports.test.ts +76 -0
- package/src/__tests__/extend.test.ts +55 -0
- package/src/__tests__/integration.test.ts +341 -0
- package/src/__tests__/middleware.test.ts +38 -0
- package/src/__tests__/navigation.test.ts +64 -0
- package/src/__tests__/request.test.ts +151 -0
- package/src/__tests__/routing.test.ts +39 -0
- package/src/__tests__/security.test.ts +293 -0
- package/src/__tests__/test-utils.ts +28 -0
- package/src/__tests__/vitest.d.ts +13 -0
- package/src/client-next.ts +31 -0
- package/src/client.ts +27 -0
- package/src/dictionaries/de.json +193 -0
- package/src/dictionaries/en.json +193 -0
- package/src/dictionaries/es.json +193 -0
- package/src/dictionaries/fr.json +193 -0
- package/src/dictionaries/pt.json +193 -0
- package/src/index.ts +24 -0
- package/src/middleware.ts +27 -0
- package/src/navigation.ts +91 -0
- package/src/request.ts +126 -0
- package/src/routing.export.ts +11 -0
- package/src/routing.ts +63 -0
- package/src/server-next.ts +57 -0
- package/src/server.ts +39 -0
- package/src/shared/constants.ts +42 -0
- package/src/shared/dictionary-loader.ts +599 -0
- package/src/shared/next-intl-adapter.ts +80 -0
- package/src/shared/next-intl-server.ts +45 -0
- package/src/shared/react19-cache.ts +132 -0
- package/src/types.ts +55 -0
- package/src/utils/extend.ts +48 -0
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Shared dictionary loading utility
|
|
3
|
+
*
|
|
4
|
+
* This utility provides type-safe dictionary loading with proper error handling
|
|
5
|
+
* and fallback mechanisms. It eliminates code duplication between server.ts and index.ts.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - In-memory LRU cache for loaded dictionaries
|
|
9
|
+
* - Cache statistics for monitoring
|
|
10
|
+
* - Error handling with fallback to default locale
|
|
11
|
+
* - Support for multiple locales
|
|
12
|
+
*
|
|
13
|
+
* @module @repo/internationalization/shared/dictionary-loader
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { logDebug, logError, logWarn } from '@repo/shared/logger';
|
|
17
|
+
|
|
18
|
+
import languine from '../../languine.json';
|
|
19
|
+
|
|
20
|
+
import type en from '../dictionaries/en.json';
|
|
21
|
+
|
|
22
|
+
const locales = [languine.locale.source, ...languine.locale.targets] as const;
|
|
23
|
+
|
|
24
|
+
export type Locale = (typeof locales)[number];
|
|
25
|
+
export type Dictionary = typeof en;
|
|
26
|
+
|
|
27
|
+
// In-memory cache for loaded dictionaries with LRU eviction
|
|
28
|
+
// Limited to prevent memory bloat in long-running Node 22 processes
|
|
29
|
+
// Can be configured via environment variable for different deployment scenarios
|
|
30
|
+
const MAX_CACHE_SIZE = Number.parseInt(process.env.I18N_CACHE_MAX_SIZE ?? '10', 10) ?? 10;
|
|
31
|
+
const dictionaryCache = new Map<Locale, Promise<Dictionary>>();
|
|
32
|
+
|
|
33
|
+
// Cache statistics for monitoring (Node 22 performance tracking)
|
|
34
|
+
// Use Number.MAX_SAFE_INTEGER to prevent overflow in long-running processes
|
|
35
|
+
const MAX_SAFE_COUNT = Number.MAX_SAFE_INTEGER;
|
|
36
|
+
const cacheStats = {
|
|
37
|
+
hits: 0,
|
|
38
|
+
misses: 0,
|
|
39
|
+
evictions: 0,
|
|
40
|
+
errors: 0,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Gets current cache statistics for monitoring and debugging.
|
|
45
|
+
* Useful for production performance analysis.
|
|
46
|
+
*
|
|
47
|
+
* @returns Cache statistics object with hits, misses, evictions, and errors
|
|
48
|
+
*/
|
|
49
|
+
export function getCacheStats() {
|
|
50
|
+
return {
|
|
51
|
+
...cacheStats,
|
|
52
|
+
size: dictionaryCache.size,
|
|
53
|
+
maxSize: MAX_CACHE_SIZE,
|
|
54
|
+
hitRate:
|
|
55
|
+
cacheStats.hits + cacheStats.misses > 0
|
|
56
|
+
? `${((cacheStats.hits / (cacheStats.hits + cacheStats.misses)) * 100).toFixed(2)}%`
|
|
57
|
+
: '0%',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resets cache statistics. Useful for testing or periodic reset.
|
|
63
|
+
* Also prevents overflow in long-running processes.
|
|
64
|
+
*
|
|
65
|
+
* Note: This only resets statistics counters, not the cache itself.
|
|
66
|
+
* To clear the cache entries, create a new dictionary loader instance.
|
|
67
|
+
*/
|
|
68
|
+
export function resetCacheStats(): void {
|
|
69
|
+
cacheStats.hits = 0;
|
|
70
|
+
cacheStats.misses = 0;
|
|
71
|
+
cacheStats.evictions = 0;
|
|
72
|
+
cacheStats.errors = 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Implements LRU (Least Recently Used) cache eviction.
|
|
77
|
+
* Moves accessed item to end of Map (most recent).
|
|
78
|
+
* Node 22 Map maintains insertion order.
|
|
79
|
+
*
|
|
80
|
+
* @param locale - The locale to mark as recently used
|
|
81
|
+
*/
|
|
82
|
+
function touchCache(locale: Locale): void {
|
|
83
|
+
const value = dictionaryCache.get(locale);
|
|
84
|
+
if (value) {
|
|
85
|
+
// Remove and re-add to move to end (most recent)
|
|
86
|
+
dictionaryCache.delete(locale);
|
|
87
|
+
dictionaryCache.set(locale, value);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Removes the least-recently-used dictionary from the in-memory cache when the cache size reaches the configured maximum.
|
|
93
|
+
*
|
|
94
|
+
* If an entry is evicted, increments the `cacheStats.evictions` counter with overflow protection and emits a debug log entry with eviction metadata when a logger is available.
|
|
95
|
+
*/
|
|
96
|
+
function evictOldestIfNeeded(): void {
|
|
97
|
+
if (dictionaryCache.size >= MAX_CACHE_SIZE) {
|
|
98
|
+
// First key is oldest (Map maintains insertion order)
|
|
99
|
+
const oldestKey = dictionaryCache.keys().next().value;
|
|
100
|
+
if (oldestKey) {
|
|
101
|
+
dictionaryCache.delete(oldestKey);
|
|
102
|
+
// Atomic increment with overflow protection
|
|
103
|
+
cacheStats.evictions = Math.min((cacheStats.evictions ?? 0) + 1, MAX_SAFE_COUNT);
|
|
104
|
+
logDebug('Dictionary cache eviction', {
|
|
105
|
+
evictedLocale: oldestKey,
|
|
106
|
+
cacheSize: dictionaryCache.size,
|
|
107
|
+
totalEvictions: cacheStats.evictions,
|
|
108
|
+
context: {
|
|
109
|
+
function: 'evictOldestIfNeeded',
|
|
110
|
+
package: '@repo/internationalization',
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Minimal valid dictionary structure for fallback
|
|
118
|
+
// Matches the complete structure of en.json to prevent runtime errors
|
|
119
|
+
const EMPTY_DICTIONARY: Dictionary = {
|
|
120
|
+
common: {
|
|
121
|
+
locale: '',
|
|
122
|
+
language: '',
|
|
123
|
+
},
|
|
124
|
+
web: {
|
|
125
|
+
global: {
|
|
126
|
+
primaryCta: '',
|
|
127
|
+
secondaryCta: '',
|
|
128
|
+
},
|
|
129
|
+
header: {
|
|
130
|
+
home: '',
|
|
131
|
+
product: {
|
|
132
|
+
title: '',
|
|
133
|
+
description: '',
|
|
134
|
+
pricing: '',
|
|
135
|
+
},
|
|
136
|
+
blog: '',
|
|
137
|
+
docs: '',
|
|
138
|
+
contact: '',
|
|
139
|
+
signIn: '',
|
|
140
|
+
signUp: '',
|
|
141
|
+
},
|
|
142
|
+
home: {
|
|
143
|
+
meta: {
|
|
144
|
+
title: '',
|
|
145
|
+
description: '',
|
|
146
|
+
},
|
|
147
|
+
hero: {
|
|
148
|
+
announcement: '',
|
|
149
|
+
},
|
|
150
|
+
cases: {
|
|
151
|
+
title: '',
|
|
152
|
+
},
|
|
153
|
+
features: {
|
|
154
|
+
title: '',
|
|
155
|
+
description: '',
|
|
156
|
+
items: [],
|
|
157
|
+
},
|
|
158
|
+
stats: {
|
|
159
|
+
title: '',
|
|
160
|
+
description: '',
|
|
161
|
+
items: [],
|
|
162
|
+
},
|
|
163
|
+
testimonials: {
|
|
164
|
+
title: '',
|
|
165
|
+
items: [],
|
|
166
|
+
},
|
|
167
|
+
faq: {
|
|
168
|
+
title: '',
|
|
169
|
+
description: '',
|
|
170
|
+
cta: '',
|
|
171
|
+
items: [],
|
|
172
|
+
},
|
|
173
|
+
cta: {
|
|
174
|
+
title: '',
|
|
175
|
+
description: '',
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
blog: {
|
|
179
|
+
meta: {
|
|
180
|
+
title: '',
|
|
181
|
+
description: '',
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
contact: {
|
|
185
|
+
meta: {
|
|
186
|
+
title: '',
|
|
187
|
+
description: '',
|
|
188
|
+
},
|
|
189
|
+
hero: {
|
|
190
|
+
benefits: [],
|
|
191
|
+
form: {
|
|
192
|
+
title: '',
|
|
193
|
+
date: '',
|
|
194
|
+
firstName: '',
|
|
195
|
+
lastName: '',
|
|
196
|
+
resume: '',
|
|
197
|
+
cta: '',
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// Use the structured empty dictionary for type-safe fallback
|
|
205
|
+
// EMPTY_DICTIONARY already provides the correct shape
|
|
206
|
+
|
|
207
|
+
// Type-safe error for dictionary loading (reserved for future use)
|
|
208
|
+
export interface _DictionaryLoadError {
|
|
209
|
+
locale: string;
|
|
210
|
+
message: string;
|
|
211
|
+
originalError: unknown;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Import a module with a timeout to prevent indefinite hangs.
|
|
216
|
+
* Uses Promise.race for timeout handling (import() cannot be cancelled).
|
|
217
|
+
*
|
|
218
|
+
* @param path - The module path to import
|
|
219
|
+
* @param timeoutMs - Timeout in milliseconds (default: 5000ms)
|
|
220
|
+
* @returns Promise that resolves to the imported module
|
|
221
|
+
* @throws Error if import times out or fails
|
|
222
|
+
*/
|
|
223
|
+
async function importWithTimeout<T>(path: string, timeoutMs = 5000): Promise<T> {
|
|
224
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
// Race between import and timeout
|
|
228
|
+
// Note: import() cannot be cancelled, but we can reject the promise on timeout
|
|
229
|
+
const result = await Promise.race([
|
|
230
|
+
import(path) as Promise<T>,
|
|
231
|
+
new Promise<never>((_resolve, reject) => {
|
|
232
|
+
timeoutId = setTimeout(() => {
|
|
233
|
+
reject(new Error(`Import timeout after ${timeoutMs}ms: ${path}`));
|
|
234
|
+
}, timeoutMs);
|
|
235
|
+
}),
|
|
236
|
+
]);
|
|
237
|
+
|
|
238
|
+
// Clear timeout if import succeeded before timeout
|
|
239
|
+
if (timeoutId !== undefined) {
|
|
240
|
+
clearTimeout(timeoutId);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return result;
|
|
244
|
+
} catch (error: unknown) {
|
|
245
|
+
// Ensure timeout is cleared on error
|
|
246
|
+
if (timeoutId !== undefined) {
|
|
247
|
+
clearTimeout(timeoutId);
|
|
248
|
+
}
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Validates and normalizes a locale string to prevent path traversal attacks.
|
|
255
|
+
*
|
|
256
|
+
* @param locale - The locale string to validate
|
|
257
|
+
* @returns The normalized locale if valid, null otherwise
|
|
258
|
+
*/
|
|
259
|
+
function validateAndNormalizeLocale(locale: string): Locale | null {
|
|
260
|
+
// Validate input length and type
|
|
261
|
+
if (!locale || typeof locale !== 'string' || locale.length > 20) {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Validate pattern (alphanumeric and hyphens only, BCP 47 format)
|
|
266
|
+
// Safe regex: bounded quantifiers ({2}, {2,4}), input length pre-validated (max 20), no catastrophic backtracking
|
|
267
|
+
// eslint-disable-next-line regexp/no-unused-capturing-group, security/detect-unsafe-regex -- Capturing group needed for validation; regex is safe (bounded quantifiers, input length limited)
|
|
268
|
+
if (!/^[a-z]{2}(-[a-z]{2,4})?$/i.test(locale)) {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Normalize to base locale (e.g., 'en-US' -> 'en')
|
|
273
|
+
const localeParts = locale.split('-');
|
|
274
|
+
const normalizedLocale = (localeParts[0] ?? locale).toLowerCase();
|
|
275
|
+
|
|
276
|
+
// Validate normalized locale exists in supported locales
|
|
277
|
+
if (!locales.includes(normalizedLocale)) {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return normalizedLocale;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Creates a type-safe dictionary loader with proper error handling
|
|
286
|
+
*
|
|
287
|
+
* @returns An object with methods to load dictionaries and check locale support
|
|
288
|
+
*/
|
|
289
|
+
export function createDictionaryLoader() {
|
|
290
|
+
const dictionaries: Record<Locale, () => Promise<Dictionary>> = Object.fromEntries(
|
|
291
|
+
locales.map(locale => [
|
|
292
|
+
locale,
|
|
293
|
+
(): Promise<Dictionary> => {
|
|
294
|
+
// Check cache first (LRU-aware)
|
|
295
|
+
if (dictionaryCache.has(locale)) {
|
|
296
|
+
touchCache(locale); // Mark as recently used
|
|
297
|
+
// Atomic increment for cache hit statistics with overflow protection
|
|
298
|
+
cacheStats.hits = Math.min((cacheStats.hits ?? 0) + 1, MAX_SAFE_COUNT);
|
|
299
|
+
const cached = dictionaryCache.get(locale);
|
|
300
|
+
if (!cached) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
`Cache inconsistency: locale ${locale} was in cache but get returned undefined`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
// Log cache hit without sensitive performance data in production
|
|
306
|
+
if (process.env.NODE_ENV === 'development') {
|
|
307
|
+
logDebug('Dictionary cache hit', {
|
|
308
|
+
locale,
|
|
309
|
+
cached: true,
|
|
310
|
+
cacheSize: dictionaryCache.size,
|
|
311
|
+
hitRate: getCacheStats().hitRate,
|
|
312
|
+
context: {
|
|
313
|
+
function: 'createDictionaryLoader',
|
|
314
|
+
package: '@repo/internationalization',
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
return cached;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Cache miss - atomic increment with overflow protection
|
|
322
|
+
cacheStats.misses = Math.min((cacheStats.misses ?? 0) + 1, MAX_SAFE_COUNT);
|
|
323
|
+
|
|
324
|
+
// Evict oldest entry if cache is full
|
|
325
|
+
evictOldestIfNeeded();
|
|
326
|
+
|
|
327
|
+
// Load and cache dictionary
|
|
328
|
+
// Track whether the original locale load succeeded (not a fallback)
|
|
329
|
+
let originalLoadSucceeded = false;
|
|
330
|
+
const loadPromise = (async (): Promise<Dictionary> => {
|
|
331
|
+
const startTime = performance.now();
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
// Locale is guaranteed safe here as it comes from the locales array
|
|
335
|
+
// Use timeout to prevent indefinite hangs
|
|
336
|
+
const mod = await importWithTimeout<{ default: Dictionary }>(
|
|
337
|
+
`../dictionaries/${locale}.json`,
|
|
338
|
+
5000,
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
// Mark that original load succeeded (not a fallback)
|
|
342
|
+
originalLoadSucceeded = true;
|
|
343
|
+
const duration = performance.now() - startTime;
|
|
344
|
+
|
|
345
|
+
const dictionary = mod.default;
|
|
346
|
+
|
|
347
|
+
logDebug('Dictionary loaded successfully', {
|
|
348
|
+
locale,
|
|
349
|
+
duration: `${duration.toFixed(2)}ms`,
|
|
350
|
+
cached: false,
|
|
351
|
+
timestamp: new Date().toISOString(),
|
|
352
|
+
context: {
|
|
353
|
+
function: 'createDictionaryLoader',
|
|
354
|
+
package: '@repo/internationalization',
|
|
355
|
+
},
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
return dictionary;
|
|
359
|
+
} catch (error: unknown) {
|
|
360
|
+
const duration = performance.now() - startTime;
|
|
361
|
+
// Atomic increment for error statistics with overflow protection
|
|
362
|
+
cacheStats.errors = Math.min((cacheStats.errors ?? 0) + 1, MAX_SAFE_COUNT);
|
|
363
|
+
const errorMessage =
|
|
364
|
+
error instanceof Error
|
|
365
|
+
? error.message
|
|
366
|
+
: typeof error === 'string'
|
|
367
|
+
? error
|
|
368
|
+
: 'Unknown error';
|
|
369
|
+
const errorStack = error instanceof Error ? error.stack : undefined;
|
|
370
|
+
|
|
371
|
+
logError(`Failed to load dictionary for locale: ${locale}`, {
|
|
372
|
+
error: errorMessage,
|
|
373
|
+
stack: errorStack,
|
|
374
|
+
locale,
|
|
375
|
+
duration: `${duration.toFixed(2)}ms`,
|
|
376
|
+
totalErrors: cacheStats.errors,
|
|
377
|
+
context: {
|
|
378
|
+
function: 'createDictionaryLoader',
|
|
379
|
+
package: '@repo/internationalization',
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// Fallback to English dictionary
|
|
384
|
+
// IMPORTANT: Do not cache fallback results - they should not be cached
|
|
385
|
+
// under the original locale key to prevent incorrect caching
|
|
386
|
+
try {
|
|
387
|
+
const fallbackStartTime = performance.now();
|
|
388
|
+
const fallbackMod = await importWithTimeout<{ default: Dictionary }>(
|
|
389
|
+
'../dictionaries/en.json',
|
|
390
|
+
5000,
|
|
391
|
+
);
|
|
392
|
+
const fallbackDuration = performance.now() - fallbackStartTime;
|
|
393
|
+
|
|
394
|
+
const fallbackDictionary = fallbackMod.default;
|
|
395
|
+
|
|
396
|
+
logDebug('English fallback dictionary loaded', {
|
|
397
|
+
originalLocale: locale,
|
|
398
|
+
fallbackLocale: 'en',
|
|
399
|
+
duration: `${fallbackDuration.toFixed(2)}ms`,
|
|
400
|
+
context: {
|
|
401
|
+
function: 'createDictionaryLoader',
|
|
402
|
+
package: '@repo/internationalization',
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// Return fallback but don't cache it - let it fail again next time
|
|
407
|
+
// This ensures we don't cache incorrect locale mappings
|
|
408
|
+
return fallbackDictionary;
|
|
409
|
+
} catch (fallbackError: unknown) {
|
|
410
|
+
logError('Critical: English fallback dictionary failed to load', {
|
|
411
|
+
error:
|
|
412
|
+
fallbackError instanceof Error
|
|
413
|
+
? fallbackError.message
|
|
414
|
+
: typeof fallbackError === 'string'
|
|
415
|
+
? fallbackError
|
|
416
|
+
: 'Unknown error',
|
|
417
|
+
stack: fallbackError instanceof Error ? fallbackError.stack : undefined,
|
|
418
|
+
locale,
|
|
419
|
+
});
|
|
420
|
+
// Bubble up failure so callers can log additional context
|
|
421
|
+
throw fallbackError;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
})();
|
|
425
|
+
|
|
426
|
+
// Wrap promise to check if original load succeeded before caching
|
|
427
|
+
const cachedPromiseWrapper = async (): Promise<Dictionary> => {
|
|
428
|
+
try {
|
|
429
|
+
const result = await loadPromise;
|
|
430
|
+
// Only keep in cache if original load succeeded (not a fallback)
|
|
431
|
+
// If original load failed and we used fallback, remove from cache
|
|
432
|
+
const cached = dictionaryCache.get(locale);
|
|
433
|
+
if (!originalLoadSucceeded && cached === cachedPromise) {
|
|
434
|
+
dictionaryCache.delete(locale);
|
|
435
|
+
}
|
|
436
|
+
return result;
|
|
437
|
+
} catch (error: unknown) {
|
|
438
|
+
// If promise rejects, remove from cache
|
|
439
|
+
const cached = dictionaryCache.get(locale);
|
|
440
|
+
if (cached === cachedPromise) {
|
|
441
|
+
dictionaryCache.delete(locale);
|
|
442
|
+
}
|
|
443
|
+
throw error;
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
// Create the promise and cache it
|
|
448
|
+
const cachedPromise = cachedPromiseWrapper();
|
|
449
|
+
|
|
450
|
+
// Cache the promise initially - we'll remove it later if it was a fallback
|
|
451
|
+
dictionaryCache.set(locale, cachedPromise);
|
|
452
|
+
|
|
453
|
+
return cachedPromise;
|
|
454
|
+
},
|
|
455
|
+
]) satisfies Array<[Locale, () => Promise<Dictionary>]>,
|
|
456
|
+
) as Record<Locale, () => Promise<Dictionary>>;
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
/**
|
|
460
|
+
* Loads a dictionary for the specified locale with proper type safety and fallbacks.
|
|
461
|
+
*
|
|
462
|
+
* @param locale - The locale string to load (e.g., 'en', 'en-US', 'es')
|
|
463
|
+
* @returns A promise that resolves to the dictionary for the locale
|
|
464
|
+
*
|
|
465
|
+
* @example
|
|
466
|
+
* ```typescript
|
|
467
|
+
* const loader = createDictionaryLoader();
|
|
468
|
+
* const dict = await loader.getDictionary('en');
|
|
469
|
+
* ```
|
|
470
|
+
*/
|
|
471
|
+
async getDictionary(locale: string): Promise<Dictionary> {
|
|
472
|
+
const startTime = performance.now();
|
|
473
|
+
|
|
474
|
+
// Validate and normalize locale to prevent path traversal
|
|
475
|
+
const validatedLocale = validateAndNormalizeLocale(locale);
|
|
476
|
+
|
|
477
|
+
if (!validatedLocale) {
|
|
478
|
+
logWarn(`Invalid or unsupported locale "${locale}", defaulting to "en"`, {
|
|
479
|
+
locale,
|
|
480
|
+
context: {
|
|
481
|
+
function: 'getDictionary',
|
|
482
|
+
package: '@repo/internationalization',
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
// Safely attempt English fallback
|
|
486
|
+
try {
|
|
487
|
+
const result = await (dictionaries.en?.() ?? Promise.resolve(EMPTY_DICTIONARY));
|
|
488
|
+
const resolvedDictionary = result;
|
|
489
|
+
const duration = performance.now() - startTime;
|
|
490
|
+
|
|
491
|
+
logDebug('Dictionary loaded via fallback', {
|
|
492
|
+
requestedLocale: locale,
|
|
493
|
+
actualLocale: 'en',
|
|
494
|
+
duration: `${duration.toFixed(2)}ms`,
|
|
495
|
+
cached: dictionaryCache.has('en'),
|
|
496
|
+
context: {
|
|
497
|
+
function: 'getDictionary',
|
|
498
|
+
package: '@repo/internationalization',
|
|
499
|
+
},
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
return resolvedDictionary;
|
|
503
|
+
} catch (error: unknown) {
|
|
504
|
+
const duration = performance.now() - startTime;
|
|
505
|
+
|
|
506
|
+
logError('Failed to load English fallback dictionary', {
|
|
507
|
+
error:
|
|
508
|
+
error instanceof Error
|
|
509
|
+
? error.message
|
|
510
|
+
: typeof error === 'string'
|
|
511
|
+
? error
|
|
512
|
+
: 'Unknown error',
|
|
513
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
514
|
+
duration: `${duration.toFixed(2)}ms`,
|
|
515
|
+
});
|
|
516
|
+
return EMPTY_DICTIONARY;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
try {
|
|
521
|
+
const result = await (dictionaries[validatedLocale]?.() ??
|
|
522
|
+
Promise.resolve(EMPTY_DICTIONARY));
|
|
523
|
+
const resolvedDictionary = result;
|
|
524
|
+
const duration = performance.now() - startTime;
|
|
525
|
+
|
|
526
|
+
logDebug('Dictionary retrieved', {
|
|
527
|
+
locale: validatedLocale,
|
|
528
|
+
duration: `${duration.toFixed(2)}ms`,
|
|
529
|
+
cached: dictionaryCache.has(validatedLocale),
|
|
530
|
+
context: {
|
|
531
|
+
function: 'getDictionary',
|
|
532
|
+
package: '@repo/internationalization',
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
return resolvedDictionary;
|
|
537
|
+
} catch (error: unknown) {
|
|
538
|
+
const duration = performance.now() - startTime;
|
|
539
|
+
|
|
540
|
+
logError(`Error loading dictionary for locale "${validatedLocale}", falling back to "en"`, {
|
|
541
|
+
error:
|
|
542
|
+
error instanceof Error
|
|
543
|
+
? error.message
|
|
544
|
+
: typeof error === 'string'
|
|
545
|
+
? error
|
|
546
|
+
: 'Unknown error',
|
|
547
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
548
|
+
locale: validatedLocale,
|
|
549
|
+
duration: `${duration.toFixed(2)}ms`,
|
|
550
|
+
context: {
|
|
551
|
+
function: 'getDictionary',
|
|
552
|
+
package: '@repo/internationalization',
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
// Safely attempt English fallback
|
|
556
|
+
try {
|
|
557
|
+
const fallbackResult = await (dictionaries.en?.() ?? Promise.resolve(EMPTY_DICTIONARY));
|
|
558
|
+
return fallbackResult;
|
|
559
|
+
} catch (fallbackError: unknown) {
|
|
560
|
+
logError('Failed to load English fallback dictionary', {
|
|
561
|
+
error:
|
|
562
|
+
fallbackError instanceof Error
|
|
563
|
+
? fallbackError.message
|
|
564
|
+
: typeof fallbackError === 'string'
|
|
565
|
+
? fallbackError
|
|
566
|
+
: 'Unknown error',
|
|
567
|
+
stack: fallbackError instanceof Error ? fallbackError.stack : undefined,
|
|
568
|
+
});
|
|
569
|
+
return EMPTY_DICTIONARY;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
},
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Get all available locales.
|
|
576
|
+
*
|
|
577
|
+
* @returns An array of all supported locale codes
|
|
578
|
+
*/
|
|
579
|
+
getLocales: () => locales,
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Check if a locale is supported.
|
|
583
|
+
*
|
|
584
|
+
* @param locale - The locale string to check
|
|
585
|
+
* @returns True if the locale is supported, false otherwise
|
|
586
|
+
*
|
|
587
|
+
* @example
|
|
588
|
+
* ```typescript
|
|
589
|
+
* const loader = createDictionaryLoader();
|
|
590
|
+
* if (loader.isLocaleSupported('en-US')) {
|
|
591
|
+
* // Load dictionary
|
|
592
|
+
* }
|
|
593
|
+
* ```
|
|
594
|
+
*/
|
|
595
|
+
isLocaleSupported: (locale: string): boolean => {
|
|
596
|
+
return validateAndNormalizeLocale(locale) !== null;
|
|
597
|
+
},
|
|
598
|
+
};
|
|
599
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview next-intl-adapter.ts
|
|
3
|
+
*
|
|
4
|
+
* Provides statically analyzable imports for next-intl modules.
|
|
5
|
+
* In test environments, falls back to mock modules.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const isTestEnv = process.env.NODE_ENV === 'test' || process.env.VITEST === 'true';
|
|
9
|
+
|
|
10
|
+
// Type declarations for the modules
|
|
11
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- Using typeof import() for module type inference
|
|
12
|
+
type MiddlewareModule = typeof import('next-intl/middleware');
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- Using typeof import() for module type inference
|
|
14
|
+
type NavigationModule = typeof import('next-intl/navigation');
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- Using typeof import() for module type inference
|
|
16
|
+
type RoutingModule = typeof import('next-intl/routing');
|
|
17
|
+
|
|
18
|
+
type LoadedModules = {
|
|
19
|
+
createMiddleware: MiddlewareModule['default'];
|
|
20
|
+
createNavigation: NavigationModule['createNavigation'];
|
|
21
|
+
defineRouting: RoutingModule['defineRouting'];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Load next-intl modules using static import paths so bundlers (Turbopack/webpack)
|
|
26
|
+
* can resolve them at build time. Variable-path dynamic imports break bundlers.
|
|
27
|
+
*/
|
|
28
|
+
const loadModules = async (): Promise<LoadedModules> => {
|
|
29
|
+
if (isTestEnv) {
|
|
30
|
+
// Test environment: use mocks
|
|
31
|
+
try {
|
|
32
|
+
const [
|
|
33
|
+
{ default: mockMiddleware },
|
|
34
|
+
{ createNavigation: mockNavigation },
|
|
35
|
+
{ defineRouting: mockRouting },
|
|
36
|
+
] = await Promise.all([
|
|
37
|
+
import('../../test/mocks/next-intl/middleware'),
|
|
38
|
+
import('../../test/mocks/next-intl/navigation'),
|
|
39
|
+
import('../../test/mocks/next-intl/routing'),
|
|
40
|
+
]);
|
|
41
|
+
return {
|
|
42
|
+
createMiddleware: mockMiddleware as unknown as MiddlewareModule['default'],
|
|
43
|
+
createNavigation: mockNavigation as unknown as NavigationModule['createNavigation'],
|
|
44
|
+
defineRouting: mockRouting as unknown as RoutingModule['defineRouting'],
|
|
45
|
+
};
|
|
46
|
+
} catch (mockError) {
|
|
47
|
+
const msg =
|
|
48
|
+
mockError instanceof Error
|
|
49
|
+
? mockError.message
|
|
50
|
+
: typeof mockError === 'string'
|
|
51
|
+
? mockError
|
|
52
|
+
: 'Unknown error';
|
|
53
|
+
throw new Error(`Failed to load next-intl test mocks: ${msg}. Tests cannot continue.`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Production: static imports so bundlers can resolve them
|
|
58
|
+
try {
|
|
59
|
+
const [middlewareMod, navigationMod, routingMod] = await Promise.all([
|
|
60
|
+
import('next-intl/middleware'),
|
|
61
|
+
import('next-intl/navigation'),
|
|
62
|
+
import('next-intl/routing'),
|
|
63
|
+
]);
|
|
64
|
+
return {
|
|
65
|
+
createMiddleware: middlewareMod.default,
|
|
66
|
+
createNavigation: navigationMod.createNavigation,
|
|
67
|
+
defineRouting: routingMod.defineRouting,
|
|
68
|
+
};
|
|
69
|
+
} catch (error) {
|
|
70
|
+
const msg =
|
|
71
|
+
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error';
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Failed to load next-intl modules in production: ${msg}. This is a critical error and the application cannot continue.`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const { createMiddleware, createNavigation, defineRouting } = await loadModules();
|
|
79
|
+
|
|
80
|
+
export { createMiddleware, createNavigation, defineRouting };
|