@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 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,3 @@
1
+ import { ApiLoaderOptions, TranslationLoader } from './types';
2
+ export declare function createApiTranslationLoader(options?: ApiLoaderOptions): TranslationLoader;
3
+ //# sourceMappingURL=api-loader.d.ts.map
@@ -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,3 @@
1
+ import { DefaultTranslations, TranslationLoader } from './types';
2
+ export declare function withDefaultTranslations(loader: TranslationLoader, defaults: DefaultTranslations): TranslationLoader;
3
+ //# sourceMappingURL=defaults.d.ts.map
@@ -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"}
@@ -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"}
@@ -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,4 @@
1
+ export { createApiTranslationLoader } from './api-loader';
2
+ export { preloadNamespaces, warmFallbackLanguages } from './preload';
3
+ export { withDefaultTranslations } from './defaults';
4
+ //# sourceMappingURL=index.js.map
@@ -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"}
@@ -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"}
@@ -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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -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
+
@@ -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
+