@hua-labs/i18n-loaders 1.0.0
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/LICENSE +21 -0
- package/README.md +415 -0
- package/dist/api-loader.d.ts +3 -0
- package/dist/api-loader.d.ts.map +1 -0
- package/dist/api-loader.js +142 -0
- package/dist/api-loader.js.map +1 -0
- package/dist/defaults.d.ts +3 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +40 -0
- package/dist/defaults.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/preload.d.ts +10 -0
- package/dist/preload.d.ts.map +1 -0
- package/dist/preload.js +28 -0
- package/dist/preload.js.map +1 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +57 -0
- package/src/api-loader.ts +199 -0
- package/src/defaults.ts +63 -0
- package/src/index.ts +12 -0
- package/src/preload.ts +64 -0
- package/src/types.ts +34 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 HUA Labs Team
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
# @hua-labs/i18n-loaders
|
|
2
|
+
|
|
3
|
+
Production-ready translation loaders, caching, and preloading utilities. Use with `@hua-labs/i18n-core` to reuse proven loading strategies from SUM API.
|
|
4
|
+
|
|
5
|
+
## Key Features
|
|
6
|
+
|
|
7
|
+
- API-based translation loader (`createApiTranslationLoader`)
|
|
8
|
+
- Built-in TTL/global cache/duplicate request prevention
|
|
9
|
+
- Namespace preloading & fallback language warming
|
|
10
|
+
- Default translation (JSON) merging (SUM API style)
|
|
11
|
+
- Works on both server and client
|
|
12
|
+
- **Production tested**: Currently used in SUM API
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm add @hua-labs/i18n-loaders
|
|
18
|
+
# or
|
|
19
|
+
npm install @hua-labs/i18n-loaders
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
### Basic Usage
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { createCoreI18n } from '@hua-labs/i18n-core';
|
|
28
|
+
import { createApiTranslationLoader } from '@hua-labs/i18n-loaders';
|
|
29
|
+
|
|
30
|
+
const loadTranslations = createApiTranslationLoader({
|
|
31
|
+
translationApiPath: '/api/translations',
|
|
32
|
+
cacheTtlMs: 60_000, // 1 minute
|
|
33
|
+
enableGlobalCache: true
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const I18nProvider = createCoreI18n({
|
|
37
|
+
defaultLanguage: 'ko',
|
|
38
|
+
fallbackLanguage: 'en',
|
|
39
|
+
namespaces: ['common', 'dashboard'],
|
|
40
|
+
translationLoader: 'custom',
|
|
41
|
+
loadTranslations
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Preloading
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { createApiTranslationLoader, preloadNamespaces } from '@hua-labs/i18n-loaders';
|
|
49
|
+
|
|
50
|
+
const loadTranslations = createApiTranslationLoader({
|
|
51
|
+
translationApiPath: '/api/translations'
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Preload required namespaces at app startup
|
|
55
|
+
preloadNamespaces('ko', ['common', 'dashboard'], loadTranslations);
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Using Default Translation Merging
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { createApiTranslationLoader, withDefaultTranslations } from '@hua-labs/i18n-loaders';
|
|
62
|
+
|
|
63
|
+
const apiLoader = createApiTranslationLoader({
|
|
64
|
+
translationApiPath: '/api/translations'
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const defaultTranslations = {
|
|
68
|
+
ko: {
|
|
69
|
+
common: {
|
|
70
|
+
welcome: 'Welcome',
|
|
71
|
+
hello: 'Hello'
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
en: {
|
|
75
|
+
common: {
|
|
76
|
+
welcome: 'Welcome',
|
|
77
|
+
hello: 'Hello'
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Use default translations if API fails, merge if API succeeds
|
|
83
|
+
const loadTranslations = withDefaultTranslations(apiLoader, defaultTranslations);
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## API Reference
|
|
87
|
+
|
|
88
|
+
### createApiTranslationLoader
|
|
89
|
+
|
|
90
|
+
Creates an API-based translation loader. Includes TTL caching, duplicate request prevention, and global cache.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
function createApiTranslationLoader(
|
|
94
|
+
options?: ApiLoaderOptions
|
|
95
|
+
): TranslationLoader
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
#### Options
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
interface ApiLoaderOptions {
|
|
102
|
+
// API path (default: '/api/translations')
|
|
103
|
+
translationApiPath?: string;
|
|
104
|
+
|
|
105
|
+
// Base URL (for server-side use)
|
|
106
|
+
baseUrl?: string;
|
|
107
|
+
|
|
108
|
+
// Local fallback URL (for development)
|
|
109
|
+
localFallbackBaseUrl?: string;
|
|
110
|
+
|
|
111
|
+
// Cache TTL (milliseconds, default: 5 minutes)
|
|
112
|
+
cacheTtlMs?: number;
|
|
113
|
+
|
|
114
|
+
// Disable cache
|
|
115
|
+
disableCache?: boolean;
|
|
116
|
+
|
|
117
|
+
// Fetch request options
|
|
118
|
+
requestInit?: RequestInit | ((language: string, namespace: string) => RequestInit | undefined);
|
|
119
|
+
|
|
120
|
+
// Custom fetcher (for testing)
|
|
121
|
+
fetcher?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
122
|
+
|
|
123
|
+
// Logger (default: console)
|
|
124
|
+
logger?: Pick<typeof console, 'log' | 'warn' | 'error'>;
|
|
125
|
+
|
|
126
|
+
// Retry configuration for network errors
|
|
127
|
+
retryCount?: number; // Number of retry attempts (default: 0, no retry)
|
|
128
|
+
retryDelay?: number; // Base delay in milliseconds (default: 1000), uses exponential backoff
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
#### Examples
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
// Basic usage
|
|
136
|
+
const loader = createApiTranslationLoader();
|
|
137
|
+
|
|
138
|
+
// Custom options
|
|
139
|
+
const loader = createApiTranslationLoader({
|
|
140
|
+
translationApiPath: '/api/v2/translations',
|
|
141
|
+
cacheTtlMs: 10 * 60 * 1000, // 10 minutes
|
|
142
|
+
disableCache: false,
|
|
143
|
+
retryCount: 3, // Retry 3 times on network errors
|
|
144
|
+
retryDelay: 1000, // 1 second base delay (exponential backoff)
|
|
145
|
+
requestInit: {
|
|
146
|
+
headers: {
|
|
147
|
+
'Authorization': 'Bearer token'
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Dynamic request options
|
|
153
|
+
const loader = createApiTranslationLoader({
|
|
154
|
+
requestInit: (language, namespace) => ({
|
|
155
|
+
headers: {
|
|
156
|
+
'X-Language': language,
|
|
157
|
+
'X-Namespace': namespace
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
});
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### preloadNamespaces
|
|
164
|
+
|
|
165
|
+
Preloads multiple namespaces in parallel.
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
function preloadNamespaces(
|
|
169
|
+
language: string,
|
|
170
|
+
namespaces: string[],
|
|
171
|
+
loader: TranslationLoader,
|
|
172
|
+
options?: PreloadOptions
|
|
173
|
+
): Promise<{
|
|
174
|
+
fulfilled: string[];
|
|
175
|
+
rejected: unknown[];
|
|
176
|
+
}>
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
#### Options
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
interface PreloadOptions {
|
|
183
|
+
// Logger (default: console)
|
|
184
|
+
logger?: Pick<typeof console, 'log' | 'warn'>;
|
|
185
|
+
|
|
186
|
+
// Suppress errors (default: false)
|
|
187
|
+
suppressErrors?: boolean;
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
#### Examples
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
import { preloadNamespaces } from '@hua-labs/i18n-loaders';
|
|
195
|
+
|
|
196
|
+
const loader = createApiTranslationLoader();
|
|
197
|
+
|
|
198
|
+
// Preload multiple namespaces
|
|
199
|
+
const result = await preloadNamespaces(
|
|
200
|
+
'ko',
|
|
201
|
+
['common', 'navigation', 'footer'],
|
|
202
|
+
loader
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
console.log(`Loaded: ${result.fulfilled.length}`);
|
|
206
|
+
console.log(`Failed: ${result.rejected.length}`);
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### warmFallbackLanguages
|
|
210
|
+
|
|
211
|
+
Pre-warms fallback languages.
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
function warmFallbackLanguages(
|
|
215
|
+
currentLanguage: string,
|
|
216
|
+
languages: string[],
|
|
217
|
+
namespaces: string[],
|
|
218
|
+
loader: TranslationLoader,
|
|
219
|
+
options?: PreloadOptions
|
|
220
|
+
): Promise<Array<{
|
|
221
|
+
fulfilled: string[];
|
|
222
|
+
rejected: unknown[];
|
|
223
|
+
}>>
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
#### Examples
|
|
227
|
+
|
|
228
|
+
```ts
|
|
229
|
+
import { warmFallbackLanguages } from '@hua-labs/i18n-loaders';
|
|
230
|
+
|
|
231
|
+
const loader = createApiTranslationLoader();
|
|
232
|
+
|
|
233
|
+
// When current language is 'ko', preload 'en', 'ja'
|
|
234
|
+
await warmFallbackLanguages(
|
|
235
|
+
'ko',
|
|
236
|
+
['ko', 'en', 'ja'],
|
|
237
|
+
['common', 'navigation'],
|
|
238
|
+
loader
|
|
239
|
+
);
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### withDefaultTranslations
|
|
243
|
+
|
|
244
|
+
Merges default translations with API translations. Uses default translations if API fails.
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
function withDefaultTranslations(
|
|
248
|
+
loader: TranslationLoader,
|
|
249
|
+
defaults: DefaultTranslations
|
|
250
|
+
): TranslationLoader
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
#### Types
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
type DefaultTranslations = Record<
|
|
257
|
+
string, // language
|
|
258
|
+
Record<string, TranslationRecord> // namespace -> translations
|
|
259
|
+
>;
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
#### Examples
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
import { withDefaultTranslations } from '@hua-labs/i18n-loaders';
|
|
266
|
+
|
|
267
|
+
const apiLoader = createApiTranslationLoader();
|
|
268
|
+
|
|
269
|
+
const defaults = {
|
|
270
|
+
ko: {
|
|
271
|
+
common: {
|
|
272
|
+
welcome: 'Welcome',
|
|
273
|
+
hello: 'Hello'
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
en: {
|
|
277
|
+
common: {
|
|
278
|
+
welcome: 'Welcome',
|
|
279
|
+
hello: 'Hello'
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const loader = withDefaultTranslations(apiLoader, defaults);
|
|
285
|
+
|
|
286
|
+
// Merges with default translations if API succeeds
|
|
287
|
+
// Uses only default translations if API fails
|
|
288
|
+
const translations = await loader('ko', 'common');
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
## Usage Scenarios
|
|
292
|
+
|
|
293
|
+
### Next.js App Router
|
|
294
|
+
|
|
295
|
+
```tsx
|
|
296
|
+
// lib/i18n-config.ts
|
|
297
|
+
import { createCoreI18n } from '@hua-labs/i18n-core';
|
|
298
|
+
import { createApiTranslationLoader, preloadNamespaces } from '@hua-labs/i18n-loaders';
|
|
299
|
+
|
|
300
|
+
const loadTranslations = createApiTranslationLoader({
|
|
301
|
+
translationApiPath: '/api/translations',
|
|
302
|
+
cacheTtlMs: 60_000
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
export const I18nProvider = createCoreI18n({
|
|
306
|
+
defaultLanguage: 'ko',
|
|
307
|
+
fallbackLanguage: 'en',
|
|
308
|
+
namespaces: ['common', 'navigation', 'footer'],
|
|
309
|
+
translationLoader: 'custom',
|
|
310
|
+
loadTranslations
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Use in app/layout.tsx
|
|
314
|
+
export default function RootLayout({ children }) {
|
|
315
|
+
// Preload on client
|
|
316
|
+
if (typeof window !== 'undefined') {
|
|
317
|
+
preloadNamespaces('ko', ['common', 'navigation'], loadTranslations);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return (
|
|
321
|
+
<html>
|
|
322
|
+
<body>
|
|
323
|
+
<I18nProvider>{children}</I18nProvider>
|
|
324
|
+
</body>
|
|
325
|
+
</html>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### Using with SSR
|
|
331
|
+
|
|
332
|
+
```tsx
|
|
333
|
+
// app/layout.tsx (Server Component)
|
|
334
|
+
import { loadSSRTranslations } from './lib/ssr-translations';
|
|
335
|
+
import { createCoreI18n } from '@hua-labs/i18n-core';
|
|
336
|
+
import { createApiTranslationLoader } from '@hua-labs/i18n-loaders';
|
|
337
|
+
|
|
338
|
+
export default async function RootLayout({ children }) {
|
|
339
|
+
// Load translations from SSR
|
|
340
|
+
const ssrTranslations = await loadSSRTranslations('ko');
|
|
341
|
+
|
|
342
|
+
// Client loader
|
|
343
|
+
const loadTranslations = createApiTranslationLoader({
|
|
344
|
+
translationApiPath: '/api/translations'
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const I18nProvider = createCoreI18n({
|
|
348
|
+
defaultLanguage: 'ko',
|
|
349
|
+
fallbackLanguage: 'en',
|
|
350
|
+
namespaces: ['common', 'navigation', 'footer'],
|
|
351
|
+
translationLoader: 'custom',
|
|
352
|
+
loadTranslations,
|
|
353
|
+
initialTranslations: ssrTranslations // Pass SSR translations
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
return (
|
|
357
|
+
<html lang="ko">
|
|
358
|
+
<body>
|
|
359
|
+
<I18nProvider>{children}</I18nProvider>
|
|
360
|
+
</body>
|
|
361
|
+
</html>
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
## Caching Behavior
|
|
367
|
+
|
|
368
|
+
- **TTL Cache**: Each translation is cached for `cacheTtlMs` duration
|
|
369
|
+
- **Duplicate Request Prevention**: Reuses existing Promise if same translation is loading
|
|
370
|
+
- **Global Cache**: Same loader instance shares cache across all components
|
|
371
|
+
|
|
372
|
+
## Error Handling
|
|
373
|
+
|
|
374
|
+
- **Automatic retry**: Network errors are automatically retried with exponential backoff (configurable via `retryCount` and `retryDelay`)
|
|
375
|
+
- Throws error on API request failure after all retries are exhausted
|
|
376
|
+
- Falls back to default translations when using `withDefaultTranslations`
|
|
377
|
+
- `preloadNamespaces` uses `Promise.allSettled` to continue even if some fail
|
|
378
|
+
|
|
379
|
+
### Retry Configuration
|
|
380
|
+
|
|
381
|
+
```ts
|
|
382
|
+
const loader = createApiTranslationLoader({
|
|
383
|
+
translationApiPath: '/api/translations',
|
|
384
|
+
retryCount: 3, // Retry up to 3 times on network errors (default: 0, no retry)
|
|
385
|
+
retryDelay: 1000, // Start with 1 second delay, doubles on each retry (exponential backoff)
|
|
386
|
+
});
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
The retry mechanism uses exponential backoff:
|
|
390
|
+
- 1st retry: waits `retryDelay` ms (e.g., 1000ms)
|
|
391
|
+
- 2nd retry: waits `retryDelay * 2` ms (e.g., 2000ms)
|
|
392
|
+
- 3rd retry: waits `retryDelay * 4` ms (e.g., 4000ms)
|
|
393
|
+
|
|
394
|
+
## Examples
|
|
395
|
+
|
|
396
|
+
- **[Next.js Example](../../examples/next-app-router-example/)** - Complete example using API loader with caching
|
|
397
|
+
|
|
398
|
+
## Error Handling Improvements
|
|
399
|
+
|
|
400
|
+
The API loader now includes enhanced error detection:
|
|
401
|
+
|
|
402
|
+
- **Network error detection**: Improved detection of network failures
|
|
403
|
+
- **HTTP status code handling**: Automatic retry for 5xx errors and 408 timeouts
|
|
404
|
+
- **Exponential backoff**: Smart retry strategy with configurable delays
|
|
405
|
+
- **Error type classification**: Better distinction between retryable and non-retryable errors
|
|
406
|
+
|
|
407
|
+
See [API Loader Guide](./docs/API_LOADER.md) for detailed error handling documentation.
|
|
408
|
+
|
|
409
|
+
## Documentation
|
|
410
|
+
|
|
411
|
+
- [API Loader Guide](./docs/API_LOADER.md) - Detailed API loader documentation and error handling
|
|
412
|
+
|
|
413
|
+
## License
|
|
414
|
+
|
|
415
|
+
MIT License
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api-loader.d.ts","sourceRoot":"","sources":["../src/api-loader.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAqB,MAAM,SAAS,CAAC;AAiDjF,wBAAgB,0BAA0B,CACxC,OAAO,GAAE,gBAAqB,GAC7B,iBAAiB,CAkJnB"}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
const FIVE_MINUTES = 5 * 60 * 1000;
|
|
2
|
+
const defaultFetcher = (input, init) => fetch(input, init);
|
|
3
|
+
/**
|
|
4
|
+
* 재시도 가능한 에러인지 확인
|
|
5
|
+
*/
|
|
6
|
+
function isRetryableError(error) {
|
|
7
|
+
// 네트워크 에러 (TypeError)
|
|
8
|
+
if (error instanceof TypeError) {
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
// Fetch API 에러 메시지 확인
|
|
12
|
+
if (error instanceof Error) {
|
|
13
|
+
const message = error.message.toLowerCase();
|
|
14
|
+
if (message.includes('failed to fetch') ||
|
|
15
|
+
message.includes('networkerror') ||
|
|
16
|
+
message.includes('network request failed')) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// Response 객체가 있는 경우 HTTP 상태 코드 확인
|
|
21
|
+
if (error && typeof error === 'object' && 'status' in error) {
|
|
22
|
+
const status = error.status;
|
|
23
|
+
// 5xx 서버 에러는 재시도 가능
|
|
24
|
+
if (status >= 500 && status < 600) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
// 408 Request Timeout도 재시도 가능
|
|
28
|
+
if (status === 408) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
export function createApiTranslationLoader(options = {}) {
|
|
35
|
+
const translationApiPath = options.translationApiPath ?? '/api/translations';
|
|
36
|
+
const cacheTtlMs = options.cacheTtlMs ?? FIVE_MINUTES;
|
|
37
|
+
const fetcher = options.fetcher ?? defaultFetcher;
|
|
38
|
+
const logger = options.logger ?? console;
|
|
39
|
+
const retryCount = options.retryCount ?? 0;
|
|
40
|
+
const retryDelay = options.retryDelay ?? 1000;
|
|
41
|
+
const localCache = new Map();
|
|
42
|
+
const inFlightRequests = new Map();
|
|
43
|
+
const buildUrl = (language, namespace) => {
|
|
44
|
+
const safeNamespace = namespace.replace(/[^a-zA-Z0-9-_]/g, '');
|
|
45
|
+
const path = `${translationApiPath}/${language}/${safeNamespace}`;
|
|
46
|
+
if (typeof window !== 'undefined') {
|
|
47
|
+
return path;
|
|
48
|
+
}
|
|
49
|
+
if (options.baseUrl) {
|
|
50
|
+
return `${options.baseUrl}${path}`;
|
|
51
|
+
}
|
|
52
|
+
if (process.env.NEXT_PUBLIC_SITE_URL) {
|
|
53
|
+
return `${process.env.NEXT_PUBLIC_SITE_URL}${path}`;
|
|
54
|
+
}
|
|
55
|
+
if (process.env.VERCEL_URL) {
|
|
56
|
+
const vercelUrl = process.env.VERCEL_URL.startsWith('http')
|
|
57
|
+
? process.env.VERCEL_URL
|
|
58
|
+
: `https://${process.env.VERCEL_URL}`;
|
|
59
|
+
return `${vercelUrl}${path}`;
|
|
60
|
+
}
|
|
61
|
+
const fallbackBase = options.localFallbackBaseUrl ?? 'http://localhost:3000';
|
|
62
|
+
return `${fallbackBase}${path}`;
|
|
63
|
+
};
|
|
64
|
+
const getRequestInit = (language, namespace) => {
|
|
65
|
+
if (typeof options.requestInit === 'function') {
|
|
66
|
+
return options.requestInit(language, namespace) ?? {};
|
|
67
|
+
}
|
|
68
|
+
return options.requestInit ?? {};
|
|
69
|
+
};
|
|
70
|
+
const getCached = (cacheKey) => {
|
|
71
|
+
if (options.disableCache) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const entry = localCache.get(cacheKey);
|
|
75
|
+
if (!entry) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (entry.expiresAt < Date.now()) {
|
|
79
|
+
localCache.delete(cacheKey);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return entry.data;
|
|
83
|
+
};
|
|
84
|
+
const setCached = (cacheKey, data) => {
|
|
85
|
+
if (options.disableCache) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
localCache.set(cacheKey, {
|
|
89
|
+
data,
|
|
90
|
+
expiresAt: Date.now() + cacheTtlMs
|
|
91
|
+
});
|
|
92
|
+
};
|
|
93
|
+
const loadTranslations = async (language, namespace) => {
|
|
94
|
+
const cacheKey = `${language}:${namespace}`;
|
|
95
|
+
const cached = getCached(cacheKey);
|
|
96
|
+
if (cached) {
|
|
97
|
+
return cached;
|
|
98
|
+
}
|
|
99
|
+
const inFlight = inFlightRequests.get(cacheKey);
|
|
100
|
+
if (inFlight) {
|
|
101
|
+
return inFlight;
|
|
102
|
+
}
|
|
103
|
+
const url = buildUrl(language, namespace);
|
|
104
|
+
const requestInit = getRequestInit(language, namespace);
|
|
105
|
+
const performRequest = async (attempt) => {
|
|
106
|
+
try {
|
|
107
|
+
const response = await fetcher(url, {
|
|
108
|
+
cache: 'no-store',
|
|
109
|
+
...requestInit
|
|
110
|
+
});
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
throw new Error(`[i18n-loaders] Failed to load ${language}/${namespace} (${response.status})`);
|
|
113
|
+
}
|
|
114
|
+
const data = (await response.json());
|
|
115
|
+
setCached(cacheKey, data);
|
|
116
|
+
return data;
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
// 재시도 가능한 에러인지 확인
|
|
120
|
+
const isRetryable = isRetryableError(error);
|
|
121
|
+
if (isRetryable && attempt < retryCount) {
|
|
122
|
+
logger.warn?.(`[i18n-loaders] Translation fetch failed (attempt ${attempt + 1}/${retryCount + 1}), retrying...`, language, namespace, error);
|
|
123
|
+
// 지수 백오프: 각 재시도마다 지연 시간 증가
|
|
124
|
+
const delay = retryDelay * Math.pow(2, attempt);
|
|
125
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
126
|
+
return performRequest(attempt + 1);
|
|
127
|
+
}
|
|
128
|
+
// 재시도 불가능하거나 재시도 횟수 초과
|
|
129
|
+
logger.warn?.('[i18n-loaders] translation fetch failed', language, namespace, error);
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
const requestPromise = performRequest(0)
|
|
134
|
+
.finally(() => {
|
|
135
|
+
inFlightRequests.delete(cacheKey);
|
|
136
|
+
});
|
|
137
|
+
inFlightRequests.set(cacheKey, requestPromise);
|
|
138
|
+
return requestPromise;
|
|
139
|
+
};
|
|
140
|
+
return loadTranslations;
|
|
141
|
+
}
|
|
142
|
+
//# sourceMappingURL=api-loader.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api-loader.js","sourceRoot":"","sources":["../src/api-loader.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"defaults.d.ts","sourceRoot":"","sources":["../src/defaults.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,mBAAmB,EACnB,iBAAiB,EAElB,MAAM,SAAS,CAAC;AAEjB,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,iBAAiB,EACzB,QAAQ,EAAE,mBAAmB,GAC5B,iBAAiB,CA2BnB"}
|
package/dist/defaults.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export function withDefaultTranslations(loader, defaults) {
|
|
2
|
+
return async (language, namespace) => {
|
|
3
|
+
const fallback = defaults[language]?.[namespace];
|
|
4
|
+
try {
|
|
5
|
+
const remote = await loader(language, namespace);
|
|
6
|
+
// API 응답이 빈 객체이거나 null/undefined인 경우 fallback 반환
|
|
7
|
+
if (!remote || (typeof remote === 'object' && Object.keys(remote).length === 0)) {
|
|
8
|
+
if (fallback) {
|
|
9
|
+
return fallback;
|
|
10
|
+
}
|
|
11
|
+
return remote || {};
|
|
12
|
+
}
|
|
13
|
+
if (!fallback) {
|
|
14
|
+
return remote;
|
|
15
|
+
}
|
|
16
|
+
return mergeTranslations(fallback, remote);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (fallback) {
|
|
20
|
+
return fallback;
|
|
21
|
+
}
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function mergeTranslations(base, override) {
|
|
27
|
+
const result = { ...base };
|
|
28
|
+
for (const [key, value] of Object.entries(override)) {
|
|
29
|
+
if (isPlainObject(value) && isPlainObject(result[key])) {
|
|
30
|
+
result[key] = mergeTranslations(result[key], value);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
result[key] = value;
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
function isPlainObject(value) {
|
|
38
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=defaults.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"defaults.js","sourceRoot":"","sources":["../src/defaults.ts"],"names":[],"mappings":"AAMA,MAAM,UAAU,uBAAuB,CACrC,MAAyB,EACzB,QAA6B;IAE7B,OAAO,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,EAAE;QACnC,MAAM,QAAQ,GAAG,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC,SAAS,CAAC,CAAC;QAEjD,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,QAAQ,EAAE,SAAS,CAAC,CAAC;YAEjD,iDAAiD;YACjD,IAAI,CAAC,MAAM,IAAI,CAAC,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,MAAM,KAAK,CAAC,CAAC,EAAE,CAAC;gBAChF,IAAI,QAAQ,EAAE,CAAC;oBACb,OAAO,QAAQ,CAAC;gBAClB,CAAC;gBACD,OAAO,MAAM,IAAI,EAAE,CAAC;YACtB,CAAC;YAED,IAAI,CAAC,QAAQ,EAAE,CAAC;gBACd,OAAO,MAAM,CAAC;YAChB,CAAC;YAED,OAAO,iBAAiB,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;QAC7C,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,QAAQ,EAAE,CAAC;gBACb,OAAO,QAAQ,CAAC;YAClB,CAAC;YACD,MAAM,KAAK,CAAC;QACd,CAAC;IACH,CAAC,CAAC;AACJ,CAAC;AAED,SAAS,iBAAiB,CACxB,IAAuB,EACvB,QAA2B;IAE3B,MAAM,MAAM,GAAsB,EAAE,GAAG,IAAI,EAAE,CAAC;IAE9C,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAC;QACpD,IAAI,aAAa,CAAC,KAAK,CAAC,IAAI,aAAa,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC;YACvD,MAAM,CAAC,GAAG,CAAC,GAAG,iBAAiB,CAC7B,MAAM,CAAC,GAAG,CAAsB,EAChC,KAA0B,CAC3B,CAAC;YACF,SAAS;QACX,CAAC;QAED,MAAM,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;IACtB,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,SAAS,aAAa,CAAC,KAAc;IACnC,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;AAC9E,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createApiTranslationLoader } from './api-loader';
|
|
2
|
+
export { preloadNamespaces, warmFallbackLanguages } from './preload';
|
|
3
|
+
export { withDefaultTranslations } from './defaults';
|
|
4
|
+
export type { ApiLoaderOptions, DefaultTranslations, PreloadOptions, TranslationLoader, TranslationRecord } from './types';
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,0BAA0B,EAAE,MAAM,cAAc,CAAC;AAC1D,OAAO,EAAE,iBAAiB,EAAE,qBAAqB,EAAE,MAAM,WAAW,CAAC;AACrE,OAAO,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAErD,YAAY,EACV,gBAAgB,EAChB,mBAAmB,EACnB,cAAc,EACd,iBAAiB,EACjB,iBAAiB,EAClB,MAAM,SAAS,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,0BAA0B,EAAE,MAAM,cAAc,CAAC;AAC1D,OAAO,EAAE,iBAAiB,EAAE,qBAAqB,EAAE,MAAM,WAAW,CAAC;AACrE,OAAO,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { PreloadOptions, TranslationLoader } from './types';
|
|
2
|
+
export declare function preloadNamespaces(language: string, namespaces: string[], loader: TranslationLoader, options?: PreloadOptions): Promise<{
|
|
3
|
+
fulfilled: string[];
|
|
4
|
+
rejected: any[];
|
|
5
|
+
}>;
|
|
6
|
+
export declare function warmFallbackLanguages(currentLanguage: string, languages: string[], namespaces: string[], loader: TranslationLoader, options?: PreloadOptions): Promise<{
|
|
7
|
+
fulfilled: string[];
|
|
8
|
+
rejected: any[];
|
|
9
|
+
}[]>;
|
|
10
|
+
//# sourceMappingURL=preload.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"preload.d.ts","sourceRoot":"","sources":["../src/preload.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAI5D,wBAAsB,iBAAiB,CACrC,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,EAAE,EACpB,MAAM,EAAE,iBAAiB,EACzB,OAAO,GAAE,cAAmB;;;GAmC7B;AAED,wBAAsB,qBAAqB,CACzC,eAAe,EAAE,MAAM,EACvB,SAAS,EAAE,MAAM,EAAE,EACnB,UAAU,EAAE,MAAM,EAAE,EACpB,MAAM,EAAE,iBAAiB,EACzB,OAAO,GAAE,cAAmB;;;KAY7B"}
|
package/dist/preload.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const defaultLogger = console;
|
|
2
|
+
export async function preloadNamespaces(language, namespaces, loader, options = {}) {
|
|
3
|
+
const logger = options.logger ?? defaultLogger;
|
|
4
|
+
const results = await Promise.allSettled(namespaces.map(async (namespace) => {
|
|
5
|
+
await loader(language, namespace);
|
|
6
|
+
return namespace;
|
|
7
|
+
}));
|
|
8
|
+
const fulfilled = results.filter((result) => result.status === 'fulfilled');
|
|
9
|
+
const rejected = results.filter((result) => result.status === 'rejected');
|
|
10
|
+
if (fulfilled.length > 0) {
|
|
11
|
+
logger.log?.(`[i18n-loaders] Preloaded ${fulfilled.length}/${namespaces.length} namespaces for ${language}`);
|
|
12
|
+
}
|
|
13
|
+
if (rejected.length > 0 && !options.suppressErrors) {
|
|
14
|
+
logger.warn?.(`[i18n-loaders] Failed to preload ${rejected.length} namespaces for ${language}`);
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
fulfilled: fulfilled.map((result) => result.value),
|
|
18
|
+
rejected: rejected.map((result) => result.reason)
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export async function warmFallbackLanguages(currentLanguage, languages, namespaces, loader, options = {}) {
|
|
22
|
+
const targets = languages.filter((language) => language !== currentLanguage);
|
|
23
|
+
if (targets.length === 0) {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
return Promise.all(targets.map((language) => preloadNamespaces(language, namespaces, loader, options)));
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=preload.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"preload.js","sourceRoot":"","sources":["../src/preload.ts"],"names":[],"mappings":"AAEA,MAAM,aAAa,GAAG,OAAO,CAAC;AAE9B,MAAM,CAAC,KAAK,UAAU,iBAAiB,CACrC,QAAgB,EAChB,UAAoB,EACpB,MAAyB,EACzB,UAA0B,EAAE;IAE5B,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,aAAa,CAAC;IAE/C,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,UAAU,CACtC,UAAU,CAAC,GAAG,CAAC,KAAK,EAAE,SAAS,EAAE,EAAE;QACjC,MAAM,MAAM,CAAC,QAAQ,EAAE,SAAS,CAAC,CAAC;QAClC,OAAO,SAAS,CAAC;IACnB,CAAC,CAAC,CACH,CAAC;IAEF,MAAM,SAAS,GAAG,OAAO,CAAC,MAAM,CAC9B,CAAC,MAAM,EAA4C,EAAE,CACnD,MAAM,CAAC,MAAM,KAAK,WAAW,CAChC,CAAC;IACF,MAAM,QAAQ,GAAG,OAAO,CAAC,MAAM,CAC7B,CAAC,MAAM,EAAmC,EAAE,CAAC,MAAM,CAAC,MAAM,KAAK,UAAU,CAC1E,CAAC;IAEF,IAAI,SAAS,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACzB,MAAM,CAAC,GAAG,EAAE,CACV,4BAA4B,SAAS,CAAC,MAAM,IAAI,UAAU,CAAC,MAAM,mBAAmB,QAAQ,EAAE,CAC/F,CAAC;IACJ,CAAC;IAED,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,cAAc,EAAE,CAAC;QACnD,MAAM,CAAC,IAAI,EAAE,CACX,oCAAoC,QAAQ,CAAC,MAAM,mBAAmB,QAAQ,EAAE,CACjF,CAAC;IACJ,CAAC;IAED,OAAO;QACL,SAAS,EAAE,SAAS,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC;QAClD,QAAQ,EAAE,QAAQ,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC;KAClD,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,qBAAqB,CACzC,eAAuB,EACvB,SAAmB,EACnB,UAAoB,EACpB,MAAyB,EACzB,UAA0B,EAAE;IAE5B,MAAM,OAAO,GAAG,SAAS,CAAC,MAAM,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,KAAK,eAAe,CAAC,CAAC;IAC7E,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACzB,OAAO,EAAE,CAAC;IACZ,CAAC;IAED,OAAO,OAAO,CAAC,GAAG,CAChB,OAAO,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,CACvB,iBAAiB,CAAC,QAAQ,EAAE,UAAU,EAAE,MAAM,EAAE,OAAO,CAAC,CACzD,CACF,CAAC;AACJ,CAAC"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type TranslationRecord = Record<string, unknown>;
|
|
2
|
+
export type TranslationLoader = (language: string, namespace: string) => Promise<TranslationRecord>;
|
|
3
|
+
export interface ApiLoaderOptions {
|
|
4
|
+
translationApiPath?: string;
|
|
5
|
+
baseUrl?: string;
|
|
6
|
+
localFallbackBaseUrl?: string;
|
|
7
|
+
cacheTtlMs?: number;
|
|
8
|
+
disableCache?: boolean;
|
|
9
|
+
requestInit?: RequestInit | ((language: string, namespace: string) => RequestInit | undefined);
|
|
10
|
+
fetcher?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
11
|
+
logger?: Pick<typeof console, 'log' | 'warn' | 'error'>;
|
|
12
|
+
/** 재시도 횟수 (기본값: 0, 재시도 안 함) */
|
|
13
|
+
retryCount?: number;
|
|
14
|
+
/** 재시도 지연 시간 (밀리초, 기본값: 1000) */
|
|
15
|
+
retryDelay?: number;
|
|
16
|
+
}
|
|
17
|
+
export interface PreloadOptions {
|
|
18
|
+
logger?: Pick<typeof console, 'log' | 'warn'>;
|
|
19
|
+
suppressErrors?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export type DefaultTranslations = Record<string, Record<string, TranslationRecord>>;
|
|
22
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,iBAAiB,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAExD,MAAM,MAAM,iBAAiB,GAAG,CAC9B,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,KACd,OAAO,CAAC,iBAAiB,CAAC,CAAC;AAEhC,MAAM,WAAW,gBAAgB;IAC/B,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,WAAW,CAAC,EACR,WAAW,GACX,CAAC,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,KAAK,WAAW,GAAG,SAAS,CAAC,CAAC;IACvE,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,GAAG,GAAG,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC9E,MAAM,CAAC,EAAE,IAAI,CAAC,OAAO,OAAO,EAAE,KAAK,GAAG,MAAM,GAAG,OAAO,CAAC,CAAC;IACxD,+BAA+B;IAC/B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,iCAAiC;IACjC,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,CAAC,EAAE,IAAI,CAAC,OAAO,OAAO,EAAE,KAAK,GAAG,MAAM,CAAC,CAAC;IAC9C,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,MAAM,mBAAmB,GAAG,MAAM,CACtC,MAAM,EACN,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAClC,CAAC"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hua-labs/i18n-loaders",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Production-ready loaders, caching, and preloading helpers for @hua-labs/i18n-core.",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"src"
|
|
10
|
+
],
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"require": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"sideEffects": false,
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@hua-labs/i18n-core": "1.0.0"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"react": ">=16.8.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^20.0.0",
|
|
27
|
+
"@types/react": "^19.2.7",
|
|
28
|
+
"eslint": "^8.0.0",
|
|
29
|
+
"react": "^19.2.0",
|
|
30
|
+
"typescript": "^5.8.3"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"i18n",
|
|
34
|
+
"internationalization",
|
|
35
|
+
"loader",
|
|
36
|
+
"preload",
|
|
37
|
+
"cache"
|
|
38
|
+
],
|
|
39
|
+
"author": "HUA Labs",
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "https://github.com/HUA-Labs/HUA-Labs-public.git"
|
|
44
|
+
},
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/HUA-Labs/HUA-Labs-public/issues"
|
|
47
|
+
},
|
|
48
|
+
"homepage": "https://github.com/HUA-Labs/HUA-Labs-public#readme",
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsc",
|
|
51
|
+
"dev": "tsc --watch",
|
|
52
|
+
"clean": "rm -rf dist",
|
|
53
|
+
"test": "echo \"No tests defined\"",
|
|
54
|
+
"lint": "echo 'Skipping lint for hua-i18n-loaders (TypeScript only, linted by tsc)'",
|
|
55
|
+
"type-check": "tsc --noEmit"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { ApiLoaderOptions, TranslationLoader, TranslationRecord } from './types';
|
|
2
|
+
|
|
3
|
+
interface CacheEntry {
|
|
4
|
+
data: TranslationRecord;
|
|
5
|
+
expiresAt: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const FIVE_MINUTES = 5 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
const defaultFetcher = (input: RequestInfo | URL, init?: RequestInit) =>
|
|
11
|
+
fetch(input, init);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 재시도 가능한 에러인지 확인
|
|
15
|
+
*/
|
|
16
|
+
function isRetryableError(error: unknown): boolean {
|
|
17
|
+
// 네트워크 에러 (TypeError)
|
|
18
|
+
if (error instanceof TypeError) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Fetch API 에러 메시지 확인
|
|
23
|
+
if (error instanceof Error) {
|
|
24
|
+
const message = error.message.toLowerCase();
|
|
25
|
+
if (
|
|
26
|
+
message.includes('failed to fetch') ||
|
|
27
|
+
message.includes('networkerror') ||
|
|
28
|
+
message.includes('network request failed')
|
|
29
|
+
) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Response 객체가 있는 경우 HTTP 상태 코드 확인
|
|
35
|
+
if (error && typeof error === 'object' && 'status' in error) {
|
|
36
|
+
const status = (error as { status: number }).status;
|
|
37
|
+
// 5xx 서버 에러는 재시도 가능
|
|
38
|
+
if (status >= 500 && status < 600) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
// 408 Request Timeout도 재시도 가능
|
|
42
|
+
if (status === 408) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createApiTranslationLoader(
|
|
51
|
+
options: ApiLoaderOptions = {}
|
|
52
|
+
): TranslationLoader {
|
|
53
|
+
const translationApiPath = options.translationApiPath ?? '/api/translations';
|
|
54
|
+
const cacheTtlMs = options.cacheTtlMs ?? FIVE_MINUTES;
|
|
55
|
+
const fetcher = options.fetcher ?? defaultFetcher;
|
|
56
|
+
const logger = options.logger ?? console;
|
|
57
|
+
const retryCount = options.retryCount ?? 0;
|
|
58
|
+
const retryDelay = options.retryDelay ?? 1000;
|
|
59
|
+
const localCache = new Map<string, CacheEntry>();
|
|
60
|
+
const inFlightRequests = new Map<string, Promise<TranslationRecord>>();
|
|
61
|
+
|
|
62
|
+
const buildUrl = (language: string, namespace: string) => {
|
|
63
|
+
const safeNamespace = namespace.replace(/[^a-zA-Z0-9-_]/g, '');
|
|
64
|
+
const path = `${translationApiPath}/${language}/${safeNamespace}`;
|
|
65
|
+
|
|
66
|
+
if (typeof window !== 'undefined') {
|
|
67
|
+
return path;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (options.baseUrl) {
|
|
71
|
+
return `${options.baseUrl}${path}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (process.env.NEXT_PUBLIC_SITE_URL) {
|
|
75
|
+
return `${process.env.NEXT_PUBLIC_SITE_URL}${path}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (process.env.VERCEL_URL) {
|
|
79
|
+
const vercelUrl = process.env.VERCEL_URL.startsWith('http')
|
|
80
|
+
? process.env.VERCEL_URL
|
|
81
|
+
: `https://${process.env.VERCEL_URL}`;
|
|
82
|
+
return `${vercelUrl}${path}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const fallbackBase = options.localFallbackBaseUrl ?? 'http://localhost:3000';
|
|
86
|
+
return `${fallbackBase}${path}`;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const getRequestInit = (language: string, namespace: string): RequestInit => {
|
|
90
|
+
if (typeof options.requestInit === 'function') {
|
|
91
|
+
return options.requestInit(language, namespace) ?? {};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return options.requestInit ?? {};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const getCached = (cacheKey: string) => {
|
|
98
|
+
if (options.disableCache) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const entry = localCache.get(cacheKey);
|
|
103
|
+
if (!entry) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (entry.expiresAt < Date.now()) {
|
|
108
|
+
localCache.delete(cacheKey);
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return entry.data;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const setCached = (cacheKey: string, data: TranslationRecord) => {
|
|
116
|
+
if (options.disableCache) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
localCache.set(cacheKey, {
|
|
121
|
+
data,
|
|
122
|
+
expiresAt: Date.now() + cacheTtlMs
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const loadTranslations: TranslationLoader = async (language, namespace) => {
|
|
127
|
+
const cacheKey = `${language}:${namespace}`;
|
|
128
|
+
const cached = getCached(cacheKey);
|
|
129
|
+
|
|
130
|
+
if (cached) {
|
|
131
|
+
return cached;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const inFlight = inFlightRequests.get(cacheKey);
|
|
135
|
+
if (inFlight) {
|
|
136
|
+
return inFlight;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const url = buildUrl(language, namespace);
|
|
140
|
+
const requestInit = getRequestInit(language, namespace);
|
|
141
|
+
|
|
142
|
+
const performRequest = async (attempt: number): Promise<TranslationRecord> => {
|
|
143
|
+
try {
|
|
144
|
+
const response = await fetcher(url, {
|
|
145
|
+
cache: 'no-store',
|
|
146
|
+
...requestInit
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`[i18n-loaders] Failed to load ${language}/${namespace} (${response.status})`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const data = (await response.json()) as TranslationRecord;
|
|
156
|
+
setCached(cacheKey, data);
|
|
157
|
+
return data;
|
|
158
|
+
} catch (error) {
|
|
159
|
+
// 재시도 가능한 에러인지 확인
|
|
160
|
+
const isRetryable = isRetryableError(error);
|
|
161
|
+
|
|
162
|
+
if (isRetryable && attempt < retryCount) {
|
|
163
|
+
logger.warn?.(
|
|
164
|
+
`[i18n-loaders] Translation fetch failed (attempt ${attempt + 1}/${retryCount + 1}), retrying...`,
|
|
165
|
+
language,
|
|
166
|
+
namespace,
|
|
167
|
+
error
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// 지수 백오프: 각 재시도마다 지연 시간 증가
|
|
171
|
+
const delay = retryDelay * Math.pow(2, attempt);
|
|
172
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
173
|
+
|
|
174
|
+
return performRequest(attempt + 1);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 재시도 불가능하거나 재시도 횟수 초과
|
|
178
|
+
logger.warn?.(
|
|
179
|
+
'[i18n-loaders] translation fetch failed',
|
|
180
|
+
language,
|
|
181
|
+
namespace,
|
|
182
|
+
error
|
|
183
|
+
);
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const requestPromise = performRequest(0)
|
|
189
|
+
.finally(() => {
|
|
190
|
+
inFlightRequests.delete(cacheKey);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
inFlightRequests.set(cacheKey, requestPromise);
|
|
194
|
+
return requestPromise;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
return loadTranslations;
|
|
198
|
+
}
|
|
199
|
+
|
package/src/defaults.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DefaultTranslations,
|
|
3
|
+
TranslationLoader,
|
|
4
|
+
TranslationRecord
|
|
5
|
+
} from './types';
|
|
6
|
+
|
|
7
|
+
export function withDefaultTranslations(
|
|
8
|
+
loader: TranslationLoader,
|
|
9
|
+
defaults: DefaultTranslations
|
|
10
|
+
): TranslationLoader {
|
|
11
|
+
return async (language, namespace) => {
|
|
12
|
+
const fallback = defaults[language]?.[namespace];
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const remote = await loader(language, namespace);
|
|
16
|
+
|
|
17
|
+
// API 응답이 빈 객체이거나 null/undefined인 경우 fallback 반환
|
|
18
|
+
if (!remote || (typeof remote === 'object' && Object.keys(remote).length === 0)) {
|
|
19
|
+
if (fallback) {
|
|
20
|
+
return fallback;
|
|
21
|
+
}
|
|
22
|
+
return remote || {};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!fallback) {
|
|
26
|
+
return remote;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return mergeTranslations(fallback, remote);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (fallback) {
|
|
32
|
+
return fallback;
|
|
33
|
+
}
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function mergeTranslations(
|
|
40
|
+
base: TranslationRecord,
|
|
41
|
+
override: TranslationRecord
|
|
42
|
+
): TranslationRecord {
|
|
43
|
+
const result: TranslationRecord = { ...base };
|
|
44
|
+
|
|
45
|
+
for (const [key, value] of Object.entries(override)) {
|
|
46
|
+
if (isPlainObject(value) && isPlainObject(result[key])) {
|
|
47
|
+
result[key] = mergeTranslations(
|
|
48
|
+
result[key] as TranslationRecord,
|
|
49
|
+
value as TranslationRecord
|
|
50
|
+
);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
result[key] = value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isPlainObject(value: unknown): value is TranslationRecord {
|
|
61
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
62
|
+
}
|
|
63
|
+
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { createApiTranslationLoader } from './api-loader';
|
|
2
|
+
export { preloadNamespaces, warmFallbackLanguages } from './preload';
|
|
3
|
+
export { withDefaultTranslations } from './defaults';
|
|
4
|
+
|
|
5
|
+
export type {
|
|
6
|
+
ApiLoaderOptions,
|
|
7
|
+
DefaultTranslations,
|
|
8
|
+
PreloadOptions,
|
|
9
|
+
TranslationLoader,
|
|
10
|
+
TranslationRecord
|
|
11
|
+
} from './types';
|
|
12
|
+
|
package/src/preload.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { PreloadOptions, TranslationLoader } from './types';
|
|
2
|
+
|
|
3
|
+
const defaultLogger = console;
|
|
4
|
+
|
|
5
|
+
export async function preloadNamespaces(
|
|
6
|
+
language: string,
|
|
7
|
+
namespaces: string[],
|
|
8
|
+
loader: TranslationLoader,
|
|
9
|
+
options: PreloadOptions = {}
|
|
10
|
+
) {
|
|
11
|
+
const logger = options.logger ?? defaultLogger;
|
|
12
|
+
|
|
13
|
+
const results = await Promise.allSettled(
|
|
14
|
+
namespaces.map(async (namespace) => {
|
|
15
|
+
await loader(language, namespace);
|
|
16
|
+
return namespace;
|
|
17
|
+
})
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const fulfilled = results.filter(
|
|
21
|
+
(result): result is PromiseFulfilledResult<string> =>
|
|
22
|
+
result.status === 'fulfilled'
|
|
23
|
+
);
|
|
24
|
+
const rejected = results.filter(
|
|
25
|
+
(result): result is PromiseRejectedResult => result.status === 'rejected'
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
if (fulfilled.length > 0) {
|
|
29
|
+
logger.log?.(
|
|
30
|
+
`[i18n-loaders] Preloaded ${fulfilled.length}/${namespaces.length} namespaces for ${language}`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (rejected.length > 0 && !options.suppressErrors) {
|
|
35
|
+
logger.warn?.(
|
|
36
|
+
`[i18n-loaders] Failed to preload ${rejected.length} namespaces for ${language}`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
fulfilled: fulfilled.map((result) => result.value),
|
|
42
|
+
rejected: rejected.map((result) => result.reason)
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function warmFallbackLanguages(
|
|
47
|
+
currentLanguage: string,
|
|
48
|
+
languages: string[],
|
|
49
|
+
namespaces: string[],
|
|
50
|
+
loader: TranslationLoader,
|
|
51
|
+
options: PreloadOptions = {}
|
|
52
|
+
) {
|
|
53
|
+
const targets = languages.filter((language) => language !== currentLanguage);
|
|
54
|
+
if (targets.length === 0) {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return Promise.all(
|
|
59
|
+
targets.map((language) =>
|
|
60
|
+
preloadNamespaces(language, namespaces, loader, options)
|
|
61
|
+
)
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type TranslationRecord = Record<string, unknown>;
|
|
2
|
+
|
|
3
|
+
export type TranslationLoader = (
|
|
4
|
+
language: string,
|
|
5
|
+
namespace: string
|
|
6
|
+
) => Promise<TranslationRecord>;
|
|
7
|
+
|
|
8
|
+
export interface ApiLoaderOptions {
|
|
9
|
+
translationApiPath?: string;
|
|
10
|
+
baseUrl?: string;
|
|
11
|
+
localFallbackBaseUrl?: string;
|
|
12
|
+
cacheTtlMs?: number;
|
|
13
|
+
disableCache?: boolean;
|
|
14
|
+
requestInit?:
|
|
15
|
+
| RequestInit
|
|
16
|
+
| ((language: string, namespace: string) => RequestInit | undefined);
|
|
17
|
+
fetcher?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
18
|
+
logger?: Pick<typeof console, 'log' | 'warn' | 'error'>;
|
|
19
|
+
/** 재시도 횟수 (기본값: 0, 재시도 안 함) */
|
|
20
|
+
retryCount?: number;
|
|
21
|
+
/** 재시도 지연 시간 (밀리초, 기본값: 1000) */
|
|
22
|
+
retryDelay?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PreloadOptions {
|
|
26
|
+
logger?: Pick<typeof console, 'log' | 'warn'>;
|
|
27
|
+
suppressErrors?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type DefaultTranslations = Record<
|
|
31
|
+
string,
|
|
32
|
+
Record<string, TranslationRecord>
|
|
33
|
+
>;
|
|
34
|
+
|