@hua-labs/i18n-loaders 1.0.0 → 1.1.0-alpha.0.1

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/src/api-loader.ts CHANGED
@@ -1,199 +1,281 @@
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
-
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
+ /**
51
+ * API 기반 번역 로더 생성
52
+ *
53
+ * @param options - 로더 옵션
54
+ * @returns 번역 로더 및 캐시 무효화 함수
55
+ *
56
+ * @example
57
+ * ```typescript
58
+ * const { loader, invalidate, clear } = createApiTranslationLoader({
59
+ * translationApiPath: '/api/translations',
60
+ * autoInvalidateInDev: true
61
+ * });
62
+ *
63
+ * // 특정 언어/네임스페이스 무효화
64
+ * invalidate('ko', 'common');
65
+ *
66
+ * // 전체 캐시 클리어
67
+ * clear();
68
+ * ```
69
+ */
70
+ export function createApiTranslationLoader(
71
+ options: ApiLoaderOptions = {}
72
+ ): TranslationLoader & { invalidate: (language?: string, namespace?: string) => void; clear: () => void } {
73
+ const translationApiPath = options.translationApiPath ?? '/api/translations';
74
+ const cacheTtlMs = options.cacheTtlMs ?? FIVE_MINUTES;
75
+ const fetcher = options.fetcher ?? defaultFetcher;
76
+ const logger = options.logger ?? console;
77
+ const retryCount = options.retryCount ?? 0;
78
+ const retryDelay = options.retryDelay ?? 1000;
79
+ const autoInvalidateInDev = options.autoInvalidateInDev ?? (typeof process !== 'undefined' && process.env.NODE_ENV === 'development');
80
+ const localCache = new Map<string, CacheEntry>();
81
+ const inFlightRequests = new Map<string, Promise<TranslationRecord>>();
82
+
83
+ const buildUrl = (language: string, namespace: string) => {
84
+ const safeNamespace = namespace.replace(/[^a-zA-Z0-9-_]/g, '');
85
+ const path = `${translationApiPath}/${language}/${safeNamespace}`;
86
+
87
+ if (typeof window !== 'undefined') {
88
+ return path;
89
+ }
90
+
91
+ if (options.baseUrl) {
92
+ return `${options.baseUrl}${path}`;
93
+ }
94
+
95
+ if (process.env.NEXT_PUBLIC_SITE_URL) {
96
+ return `${process.env.NEXT_PUBLIC_SITE_URL}${path}`;
97
+ }
98
+
99
+ if (process.env.VERCEL_URL) {
100
+ const vercelUrl = process.env.VERCEL_URL.startsWith('http')
101
+ ? process.env.VERCEL_URL
102
+ : `https://${process.env.VERCEL_URL}`;
103
+ return `${vercelUrl}${path}`;
104
+ }
105
+
106
+ const fallbackBase = options.localFallbackBaseUrl ?? 'http://localhost:3000';
107
+ return `${fallbackBase}${path}`;
108
+ };
109
+
110
+ const getRequestInit = (language: string, namespace: string): RequestInit => {
111
+ if (typeof options.requestInit === 'function') {
112
+ return options.requestInit(language, namespace) ?? {};
113
+ }
114
+
115
+ return options.requestInit ?? {};
116
+ };
117
+
118
+ const getCached = (cacheKey: string) => {
119
+ if (options.disableCache) {
120
+ return null;
121
+ }
122
+
123
+ const entry = localCache.get(cacheKey);
124
+ if (!entry) {
125
+ return null;
126
+ }
127
+
128
+ if (entry.expiresAt < Date.now()) {
129
+ localCache.delete(cacheKey);
130
+ return null;
131
+ }
132
+
133
+ return entry.data;
134
+ };
135
+
136
+ const setCached = (cacheKey: string, data: TranslationRecord) => {
137
+ if (options.disableCache) {
138
+ return;
139
+ }
140
+
141
+ localCache.set(cacheKey, {
142
+ data,
143
+ expiresAt: Date.now() + cacheTtlMs
144
+ });
145
+ };
146
+
147
+ const loadTranslations: TranslationLoader = async (language, namespace) => {
148
+ const cacheKey = `${language}:${namespace}`;
149
+ const cached = getCached(cacheKey);
150
+
151
+ if (cached) {
152
+ return cached;
153
+ }
154
+
155
+ const inFlight = inFlightRequests.get(cacheKey);
156
+ if (inFlight) {
157
+ return inFlight;
158
+ }
159
+
160
+ const url = buildUrl(language, namespace);
161
+ const requestInit = getRequestInit(language, namespace);
162
+
163
+ const performRequest = async (attempt: number): Promise<TranslationRecord> => {
164
+ try {
165
+ const response = await fetcher(url, {
166
+ cache: 'no-store',
167
+ ...requestInit
168
+ });
169
+
170
+ if (!response.ok) {
171
+ throw new Error(
172
+ `[i18n-loaders] Failed to load ${language}/${namespace} (${response.status})`
173
+ );
174
+ }
175
+
176
+ const data = (await response.json()) as TranslationRecord;
177
+ setCached(cacheKey, data);
178
+ return data;
179
+ } catch (error) {
180
+ // 재시도 가능한 에러인지 확인
181
+ const isRetryable = isRetryableError(error);
182
+
183
+ if (isRetryable && attempt < retryCount) {
184
+ logger.warn?.(
185
+ `[i18n-loaders] Translation fetch failed (attempt ${attempt + 1}/${retryCount + 1}), retrying...`,
186
+ language,
187
+ namespace,
188
+ error
189
+ );
190
+
191
+ // 지수 백오프: 각 재시도마다 지연 시간 증가
192
+ const delay = retryDelay * Math.pow(2, attempt);
193
+ await new Promise(resolve => setTimeout(resolve, delay));
194
+
195
+ return performRequest(attempt + 1);
196
+ }
197
+
198
+ // 재시도 불가능하거나 재시도 횟수 초과
199
+ logger.warn?.(
200
+ '[i18n-loaders] translation fetch failed',
201
+ language,
202
+ namespace,
203
+ error
204
+ );
205
+ throw error;
206
+ }
207
+ };
208
+
209
+ const requestPromise = performRequest(0)
210
+ .finally(() => {
211
+ inFlightRequests.delete(cacheKey);
212
+ });
213
+
214
+ inFlightRequests.set(cacheKey, requestPromise);
215
+ return requestPromise;
216
+ };
217
+
218
+ /**
219
+ * 특정 언어/네임스페이스의 캐시 무효화
220
+ */
221
+ const invalidate = (language?: string, namespace?: string) => {
222
+ if (language && namespace) {
223
+ // 특정 언어/네임스페이스만 무효화
224
+ const cacheKey = `${language}:${namespace}`;
225
+ localCache.delete(cacheKey);
226
+ logger.log?.('[i18n-loaders] Cache invalidated:', cacheKey);
227
+ } else if (language) {
228
+ // 특정 언어의 모든 네임스페이스 무효화
229
+ const prefix = `${language}:`;
230
+ for (const key of localCache.keys()) {
231
+ if (key.startsWith(prefix)) {
232
+ localCache.delete(key);
233
+ }
234
+ }
235
+ logger.log?.('[i18n-loaders] Cache invalidated for language:', language);
236
+ } else if (namespace) {
237
+ // 특정 네임스페이스의 모든 언어 무효화
238
+ const suffix = `:${namespace}`;
239
+ for (const key of localCache.keys()) {
240
+ if (key.endsWith(suffix)) {
241
+ localCache.delete(key);
242
+ }
243
+ }
244
+ logger.log?.('[i18n-loaders] Cache invalidated for namespace:', namespace);
245
+ } else {
246
+ // 전체 캐시 무효화
247
+ clear();
248
+ }
249
+ };
250
+
251
+ /**
252
+ * 전체 캐시 클리어
253
+ */
254
+ const clear = () => {
255
+ localCache.clear();
256
+ logger.log?.('[i18n-loaders] Cache cleared');
257
+ };
258
+
259
+ // 개발 모드에서 자동 무효화 설정
260
+ if (autoInvalidateInDev && typeof window !== 'undefined') {
261
+ // 개발 모드에서 페이지 포커스 시 캐시 무효화 (번역 파일 변경 감지)
262
+ window.addEventListener('focus', () => {
263
+ if (process.env.NODE_ENV === 'development') {
264
+ logger.log?.('[i18n-loaders] Development mode: Auto-invalidating cache on focus');
265
+ clear();
266
+ }
267
+ });
268
+ }
269
+
270
+ // loadTranslations 함수에 invalidate와 clear 메서드 추가
271
+ const loader = loadTranslations as TranslationLoader & {
272
+ invalidate: (language?: string, namespace?: string) => void;
273
+ clear: () => void;
274
+ };
275
+
276
+ loader.invalidate = invalidate;
277
+ loader.clear = clear;
278
+
279
+ return loader;
280
+ }
281
+
package/src/defaults.ts CHANGED
@@ -1,63 +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
-
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 CHANGED
@@ -1,12 +1,13 @@
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
-
1
+ export { createApiTranslationLoader } from './api-loader';
2
+ export { preloadNamespaces, warmFallbackLanguages } from './preload';
3
+ export { withDefaultTranslations } from './defaults';
4
+
5
+ export type {
6
+ ApiLoaderOptions,
7
+ CacheInvalidation,
8
+ DefaultTranslations,
9
+ PreloadOptions,
10
+ TranslationLoader,
11
+ TranslationRecord
12
+ } from './types';
13
+