viewlogic 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 +519 -0
- package/dist/viewlogic-router.js +2476 -0
- package/dist/viewlogic-router.js.map +7 -0
- package/dist/viewlogic-router.min.js +53 -0
- package/dist/viewlogic-router.min.js.map +7 -0
- package/dist/viewlogic-router.umd.js +120 -0
- package/package.json +85 -0
- package/src/core/ComponentLoader.js +182 -0
- package/src/core/ErrorHandler.js +331 -0
- package/src/core/RouteLoader.js +368 -0
- package/src/plugins/AuthManager.js +505 -0
- package/src/plugins/CacheManager.js +352 -0
- package/src/plugins/I18nManager.js +507 -0
- package/src/plugins/QueryManager.js +402 -0
- package/src/viewlogic-router.js +465 -0
|
@@ -0,0 +1,2476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ViewLogic v1.0.0
|
|
3
|
+
* (c) 2024 hopegiver
|
|
4
|
+
* @license MIT
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// src/plugins/I18nManager.js
|
|
8
|
+
var I18nManager = class {
|
|
9
|
+
constructor(router, options = {}) {
|
|
10
|
+
this.config = {
|
|
11
|
+
enabled: options.useI18n !== void 0 ? options.useI18n : true,
|
|
12
|
+
defaultLanguage: options.defaultLanguage || "ko",
|
|
13
|
+
fallbackLanguage: options.defaultLanguage || "ko",
|
|
14
|
+
cacheKey: options.cacheKey || "viewlogic_lang",
|
|
15
|
+
dataCacheKey: options.dataCacheKey || "viewlogic_i18n_data",
|
|
16
|
+
cacheVersion: options.cacheVersion || "1.0.0",
|
|
17
|
+
enableDataCache: options.enableDataCache !== false,
|
|
18
|
+
debug: options.debug || false
|
|
19
|
+
};
|
|
20
|
+
this.router = router;
|
|
21
|
+
this.messages = /* @__PURE__ */ new Map();
|
|
22
|
+
this.currentLanguage = this.config.defaultLanguage;
|
|
23
|
+
this.isLoading = false;
|
|
24
|
+
this.loadPromises = /* @__PURE__ */ new Map();
|
|
25
|
+
this.listeners = {
|
|
26
|
+
languageChanged: []
|
|
27
|
+
};
|
|
28
|
+
this.initPromise = this.init();
|
|
29
|
+
}
|
|
30
|
+
async init() {
|
|
31
|
+
if (!this.config.enabled) {
|
|
32
|
+
this.log("info", "I18n system disabled");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
this.loadLanguageFromCache();
|
|
36
|
+
if (this.config.debug) {
|
|
37
|
+
this.config.enableDataCache = false;
|
|
38
|
+
this.log("debug", "Data cache disabled in debug mode");
|
|
39
|
+
}
|
|
40
|
+
if (!this.messages.has(this.currentLanguage)) {
|
|
41
|
+
try {
|
|
42
|
+
await this.loadMessages(this.currentLanguage);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
this.log("error", "Failed to load initial language file:", error);
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
this.log("debug", "Language messages already loaded:", this.currentLanguage);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 캐시에서 언어 설정 로드
|
|
52
|
+
*/
|
|
53
|
+
loadLanguageFromCache() {
|
|
54
|
+
try {
|
|
55
|
+
const cachedLang = localStorage.getItem(this.config.cacheKey);
|
|
56
|
+
if (cachedLang && this.isValidLanguage(cachedLang)) {
|
|
57
|
+
this.currentLanguage = cachedLang;
|
|
58
|
+
this.log("debug", "Language loaded from cache:", cachedLang);
|
|
59
|
+
}
|
|
60
|
+
} catch (error) {
|
|
61
|
+
this.log("warn", "Failed to load language from cache:", error);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* 언어 유효성 검사
|
|
66
|
+
*/
|
|
67
|
+
isValidLanguage(lang) {
|
|
68
|
+
return typeof lang === "string" && /^[a-z]{2}$/.test(lang);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* 현재 언어 반환
|
|
72
|
+
*/
|
|
73
|
+
getCurrentLanguage() {
|
|
74
|
+
return this.currentLanguage;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* 언어 변경
|
|
78
|
+
*/
|
|
79
|
+
async setLanguage(language) {
|
|
80
|
+
if (!this.isValidLanguage(language)) {
|
|
81
|
+
this.log("warn", "Invalid language code:", language);
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
if (this.currentLanguage === language) {
|
|
85
|
+
this.log("debug", "Language already set to:", language);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
const oldLanguage = this.currentLanguage;
|
|
89
|
+
this.currentLanguage = language;
|
|
90
|
+
try {
|
|
91
|
+
await this.loadMessages(language);
|
|
92
|
+
this.saveLanguageToCache(language);
|
|
93
|
+
this.emit("languageChanged", {
|
|
94
|
+
from: oldLanguage,
|
|
95
|
+
to: language,
|
|
96
|
+
messages: this.messages.get(language)
|
|
97
|
+
});
|
|
98
|
+
this.log("info", "Language changed successfully", { from: oldLanguage, to: language });
|
|
99
|
+
return true;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
this.currentLanguage = oldLanguage;
|
|
102
|
+
this.log("error", "Failed to change language:", error);
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* 언어를 캐시에 저장
|
|
108
|
+
*/
|
|
109
|
+
saveLanguageToCache(language) {
|
|
110
|
+
try {
|
|
111
|
+
localStorage.setItem(this.config.cacheKey, language);
|
|
112
|
+
this.log("debug", "Language saved to cache:", language);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
this.log("warn", "Failed to save language to cache:", error);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* 언어 메시지 파일 로드
|
|
119
|
+
*/
|
|
120
|
+
async loadMessages(language) {
|
|
121
|
+
if (this.messages.has(language)) {
|
|
122
|
+
this.log("debug", "Messages already loaded for:", language);
|
|
123
|
+
return this.messages.get(language);
|
|
124
|
+
}
|
|
125
|
+
if (this.loadPromises.has(language)) {
|
|
126
|
+
this.log("debug", "Messages loading in progress for:", language);
|
|
127
|
+
return await this.loadPromises.get(language);
|
|
128
|
+
}
|
|
129
|
+
const loadPromise = this._loadMessagesFromFile(language);
|
|
130
|
+
this.loadPromises.set(language, loadPromise);
|
|
131
|
+
try {
|
|
132
|
+
const messages = await loadPromise;
|
|
133
|
+
this.messages.set(language, messages);
|
|
134
|
+
this.loadPromises.delete(language);
|
|
135
|
+
this.log("debug", "Messages loaded successfully for:", language);
|
|
136
|
+
return messages;
|
|
137
|
+
} catch (error) {
|
|
138
|
+
this.loadPromises.delete(language);
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* 파일에서 메시지 로드 (캐싱 지원)
|
|
144
|
+
*/
|
|
145
|
+
async _loadMessagesFromFile(language) {
|
|
146
|
+
if (this.config.enableDataCache) {
|
|
147
|
+
const cachedData = this.getDataFromCache(language);
|
|
148
|
+
if (cachedData) {
|
|
149
|
+
this.log("debug", "Messages loaded from cache:", language);
|
|
150
|
+
return cachedData;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const response = await fetch(`../i18n/${language}.json`);
|
|
155
|
+
if (!response.ok) {
|
|
156
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
157
|
+
}
|
|
158
|
+
const messages = await response.json();
|
|
159
|
+
if (this.config.enableDataCache) {
|
|
160
|
+
this.saveDataToCache(language, messages);
|
|
161
|
+
}
|
|
162
|
+
return messages;
|
|
163
|
+
} catch (error) {
|
|
164
|
+
this.log("error", "Failed to load messages file for:", language, error);
|
|
165
|
+
if (language !== this.config.fallbackLanguage) {
|
|
166
|
+
this.log("info", "Trying fallback language:", this.config.fallbackLanguage);
|
|
167
|
+
return await this._loadMessagesFromFile(this.config.fallbackLanguage);
|
|
168
|
+
}
|
|
169
|
+
throw new Error(`Failed to load messages for language: ${language}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* 언어 데이터를 캐시에서 가져오기
|
|
174
|
+
*/
|
|
175
|
+
getDataFromCache(language) {
|
|
176
|
+
try {
|
|
177
|
+
const cacheKey = `${this.config.dataCacheKey}_${language}_${this.config.cacheVersion}`;
|
|
178
|
+
const cachedItem = localStorage.getItem(cacheKey);
|
|
179
|
+
if (cachedItem) {
|
|
180
|
+
const { data, timestamp, version } = JSON.parse(cachedItem);
|
|
181
|
+
if (version !== this.config.cacheVersion) {
|
|
182
|
+
this.log("debug", "Cache version mismatch, clearing:", language);
|
|
183
|
+
localStorage.removeItem(cacheKey);
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
const now = Date.now();
|
|
187
|
+
const maxAge = 24 * 60 * 60 * 1e3;
|
|
188
|
+
if (now - timestamp > maxAge) {
|
|
189
|
+
this.log("debug", "Cache expired, removing:", language);
|
|
190
|
+
localStorage.removeItem(cacheKey);
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
return data;
|
|
194
|
+
}
|
|
195
|
+
} catch (error) {
|
|
196
|
+
this.log("warn", "Failed to read from cache:", error);
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* 언어 데이터를 캐시에 저장
|
|
202
|
+
*/
|
|
203
|
+
saveDataToCache(language, data) {
|
|
204
|
+
try {
|
|
205
|
+
const cacheKey = `${this.config.dataCacheKey}_${language}_${this.config.cacheVersion}`;
|
|
206
|
+
const cacheItem = {
|
|
207
|
+
data,
|
|
208
|
+
timestamp: Date.now(),
|
|
209
|
+
version: this.config.cacheVersion
|
|
210
|
+
};
|
|
211
|
+
localStorage.setItem(cacheKey, JSON.stringify(cacheItem));
|
|
212
|
+
this.log("debug", "Data saved to cache:", language);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
this.log("warn", "Failed to save to cache:", error);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* 메시지 번역
|
|
219
|
+
*/
|
|
220
|
+
t(key, params = {}) {
|
|
221
|
+
if (!this.config.enabled) {
|
|
222
|
+
return key;
|
|
223
|
+
}
|
|
224
|
+
const messages = this.messages.get(this.currentLanguage);
|
|
225
|
+
if (!messages) {
|
|
226
|
+
this.log("warn", "No messages loaded for current language:", this.currentLanguage);
|
|
227
|
+
return key;
|
|
228
|
+
}
|
|
229
|
+
const message = this.getNestedValue(messages, key);
|
|
230
|
+
if (message === void 0) {
|
|
231
|
+
this.log("warn", "Translation not found for key:", key);
|
|
232
|
+
const fallbackMessages = this.messages.get(this.config.fallbackLanguage);
|
|
233
|
+
if (fallbackMessages && this.currentLanguage !== this.config.fallbackLanguage) {
|
|
234
|
+
const fallbackMessage = this.getNestedValue(fallbackMessages, key);
|
|
235
|
+
if (fallbackMessage !== void 0) {
|
|
236
|
+
return this.interpolate(fallbackMessage, params);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return key;
|
|
240
|
+
}
|
|
241
|
+
return this.interpolate(message, params);
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* 중첩된 객체에서 값 가져오기
|
|
245
|
+
*/
|
|
246
|
+
getNestedValue(obj, path) {
|
|
247
|
+
return path.split(".").reduce((current, key) => {
|
|
248
|
+
return current && current[key] !== void 0 ? current[key] : void 0;
|
|
249
|
+
}, obj);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* 문자열 보간 처리
|
|
253
|
+
*/
|
|
254
|
+
interpolate(message, params) {
|
|
255
|
+
if (typeof message !== "string") {
|
|
256
|
+
return message;
|
|
257
|
+
}
|
|
258
|
+
return message.replace(/\{(\w+)\}/g, (match, key) => {
|
|
259
|
+
return params.hasOwnProperty(key) ? params[key] : match;
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* 복수형 처리
|
|
264
|
+
*/
|
|
265
|
+
plural(key, count, params = {}) {
|
|
266
|
+
const pluralKey = count === 1 ? `${key}.singular` : `${key}.plural`;
|
|
267
|
+
return this.t(pluralKey, { ...params, count });
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* 사용 가능한 언어 목록
|
|
271
|
+
*/
|
|
272
|
+
getAvailableLanguages() {
|
|
273
|
+
return ["ko", "en"];
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* 언어 변경 이벤트 리스너 등록
|
|
277
|
+
*/
|
|
278
|
+
on(event, callback) {
|
|
279
|
+
if (this.listeners[event]) {
|
|
280
|
+
this.listeners[event].push(callback);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* 언어 변경 이벤트 리스너 제거
|
|
285
|
+
*/
|
|
286
|
+
off(event, callback) {
|
|
287
|
+
if (this.listeners[event]) {
|
|
288
|
+
const index = this.listeners[event].indexOf(callback);
|
|
289
|
+
if (index > -1) {
|
|
290
|
+
this.listeners[event].splice(index, 1);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* 이벤트 발생
|
|
296
|
+
*/
|
|
297
|
+
emit(event, data) {
|
|
298
|
+
if (this.listeners[event]) {
|
|
299
|
+
this.listeners[event].forEach((callback) => {
|
|
300
|
+
try {
|
|
301
|
+
callback(data);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
this.log("error", "Error in event listener:", error);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* 현재 언어의 모든 메시지 반환
|
|
310
|
+
*/
|
|
311
|
+
getMessages() {
|
|
312
|
+
return this.messages.get(this.currentLanguage) || {};
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* 언어별 날짜 포맷팅
|
|
316
|
+
*/
|
|
317
|
+
formatDate(date, options = {}) {
|
|
318
|
+
const locale = this.currentLanguage === "ko" ? "ko-KR" : "en-US";
|
|
319
|
+
return new Intl.DateTimeFormat(locale, options).format(new Date(date));
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* 언어별 숫자 포맷팅
|
|
323
|
+
*/
|
|
324
|
+
formatNumber(number, options = {}) {
|
|
325
|
+
const locale = this.currentLanguage === "ko" ? "ko-KR" : "en-US";
|
|
326
|
+
return new Intl.NumberFormat(locale, options).format(number);
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* 로깅 래퍼 메서드
|
|
330
|
+
*/
|
|
331
|
+
log(level, ...args) {
|
|
332
|
+
if (this.router?.errorHandler) {
|
|
333
|
+
this.router.errorHandler.log(level, "I18nManager", ...args);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* i18n 활성화 여부 확인
|
|
338
|
+
*/
|
|
339
|
+
isEnabled() {
|
|
340
|
+
return this.config.enabled;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* 초기 로딩이 완료되었는지 확인
|
|
344
|
+
*/
|
|
345
|
+
async isReady() {
|
|
346
|
+
if (!this.config.enabled) {
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
await this.initPromise;
|
|
351
|
+
return true;
|
|
352
|
+
} catch (error) {
|
|
353
|
+
this.log("error", "I18n initialization failed:", error);
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* 캐시 초기화 (버전 변경 시 사용)
|
|
359
|
+
*/
|
|
360
|
+
clearCache() {
|
|
361
|
+
try {
|
|
362
|
+
const keys = Object.keys(localStorage);
|
|
363
|
+
const cacheKeys = keys.filter((key) => key.startsWith(this.config.dataCacheKey));
|
|
364
|
+
cacheKeys.forEach((key) => {
|
|
365
|
+
localStorage.removeItem(key);
|
|
366
|
+
});
|
|
367
|
+
this.log("debug", "Cache cleared, removed", cacheKeys.length, "items");
|
|
368
|
+
} catch (error) {
|
|
369
|
+
this.log("warn", "Failed to clear cache:", error);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* 캐시 상태 확인
|
|
374
|
+
*/
|
|
375
|
+
getCacheInfo() {
|
|
376
|
+
const info = {
|
|
377
|
+
enabled: this.config.enableDataCache,
|
|
378
|
+
version: this.config.cacheVersion,
|
|
379
|
+
languages: {}
|
|
380
|
+
};
|
|
381
|
+
try {
|
|
382
|
+
const keys = Object.keys(localStorage);
|
|
383
|
+
const cacheKeys = keys.filter((key) => key.startsWith(this.config.dataCacheKey));
|
|
384
|
+
cacheKeys.forEach((key) => {
|
|
385
|
+
const match = key.match(new RegExp(`${this.config.dataCacheKey}_(w+)_(.+)`));
|
|
386
|
+
if (match) {
|
|
387
|
+
const [, language, version] = match;
|
|
388
|
+
const cachedItem = JSON.parse(localStorage.getItem(key));
|
|
389
|
+
info.languages[language] = {
|
|
390
|
+
version,
|
|
391
|
+
timestamp: cachedItem.timestamp,
|
|
392
|
+
age: Date.now() - cachedItem.timestamp
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
} catch (error) {
|
|
397
|
+
this.log("warn", "Failed to get cache info:", error);
|
|
398
|
+
}
|
|
399
|
+
return info;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* 시스템 초기화 (현재 언어의 메시지 로드)
|
|
403
|
+
*/
|
|
404
|
+
async initialize() {
|
|
405
|
+
if (!this.config.enabled) {
|
|
406
|
+
this.log("info", "I18n system is disabled, skipping initialization");
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
try {
|
|
410
|
+
await this.initPromise;
|
|
411
|
+
this.log("info", "I18n system fully initialized");
|
|
412
|
+
return true;
|
|
413
|
+
} catch (error) {
|
|
414
|
+
this.log("error", "Failed to initialize I18n system:", error);
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
// src/plugins/AuthManager.js
|
|
421
|
+
var AuthManager = class {
|
|
422
|
+
constructor(router, options = {}) {
|
|
423
|
+
this.config = {
|
|
424
|
+
enabled: options.authEnabled || false,
|
|
425
|
+
loginRoute: options.loginRoute || "login",
|
|
426
|
+
protectedRoutes: options.protectedRoutes || [],
|
|
427
|
+
protectedPrefixes: options.protectedPrefixes || [],
|
|
428
|
+
publicRoutes: options.publicRoutes || ["login", "register", "home"],
|
|
429
|
+
checkAuthFunction: options.checkAuthFunction || null,
|
|
430
|
+
redirectAfterLogin: options.redirectAfterLogin || "home",
|
|
431
|
+
// 쿠키/스토리지 설정
|
|
432
|
+
authCookieName: options.authCookieName || "authToken",
|
|
433
|
+
authFallbackCookieNames: options.authFallbackCookieNames || ["accessToken", "token", "jwt"],
|
|
434
|
+
authStorage: options.authStorage || "cookie",
|
|
435
|
+
authCookieOptions: options.authCookieOptions || {},
|
|
436
|
+
authSkipValidation: options.authSkipValidation || false,
|
|
437
|
+
debug: options.debug || false
|
|
438
|
+
};
|
|
439
|
+
this.router = router;
|
|
440
|
+
this.eventListeners = /* @__PURE__ */ new Map();
|
|
441
|
+
this.log("info", "AuthManager initialized", { enabled: this.config.enabled });
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* 로깅 래퍼 메서드
|
|
445
|
+
*/
|
|
446
|
+
log(level, ...args) {
|
|
447
|
+
if (this.router?.errorHandler) {
|
|
448
|
+
this.router.errorHandler.log(level, "AuthManager", ...args);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* 라우트 인증 확인
|
|
453
|
+
*/
|
|
454
|
+
async checkAuthentication(routeName) {
|
|
455
|
+
if (!this.config.enabled) {
|
|
456
|
+
return { allowed: true, reason: "auth_disabled" };
|
|
457
|
+
}
|
|
458
|
+
this.log("debug", `\u{1F510} Checking authentication for route: ${routeName}`);
|
|
459
|
+
if (this.isPublicRoute(routeName)) {
|
|
460
|
+
return { allowed: true, reason: "public_route" };
|
|
461
|
+
}
|
|
462
|
+
const isProtected = this.isProtectedRoute(routeName);
|
|
463
|
+
if (!isProtected) {
|
|
464
|
+
return { allowed: true, reason: "not_protected" };
|
|
465
|
+
}
|
|
466
|
+
if (typeof this.config.checkAuthFunction === "function") {
|
|
467
|
+
try {
|
|
468
|
+
const isAuthenticated2 = await this.config.checkAuthFunction(routeName);
|
|
469
|
+
return {
|
|
470
|
+
allowed: isAuthenticated2,
|
|
471
|
+
reason: isAuthenticated2 ? "custom_auth_success" : "custom_auth_failed",
|
|
472
|
+
routeName
|
|
473
|
+
};
|
|
474
|
+
} catch (error) {
|
|
475
|
+
this.log("error", "Custom auth function failed:", error);
|
|
476
|
+
return { allowed: false, reason: "custom_auth_error", error };
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
const isAuthenticated = this.isUserAuthenticated();
|
|
480
|
+
return {
|
|
481
|
+
allowed: isAuthenticated,
|
|
482
|
+
reason: isAuthenticated ? "authenticated" : "not_authenticated",
|
|
483
|
+
routeName
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* 사용자 인증 상태 확인
|
|
488
|
+
*/
|
|
489
|
+
isUserAuthenticated() {
|
|
490
|
+
this.log("debug", "\u{1F50D} Checking user authentication status");
|
|
491
|
+
const token = localStorage.getItem("authToken") || localStorage.getItem("accessToken");
|
|
492
|
+
if (token) {
|
|
493
|
+
try {
|
|
494
|
+
if (token.includes(".")) {
|
|
495
|
+
const payload = JSON.parse(atob(token.split(".")[1]));
|
|
496
|
+
if (payload.exp && Date.now() >= payload.exp * 1e3) {
|
|
497
|
+
this.log("debug", "localStorage token expired, removing...");
|
|
498
|
+
localStorage.removeItem("authToken");
|
|
499
|
+
localStorage.removeItem("accessToken");
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
this.log("debug", "\u2705 Valid token found in localStorage");
|
|
504
|
+
return true;
|
|
505
|
+
} catch (error) {
|
|
506
|
+
this.log("warn", "Invalid token in localStorage:", error);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const sessionToken = sessionStorage.getItem("authToken") || sessionStorage.getItem("accessToken");
|
|
510
|
+
if (sessionToken) {
|
|
511
|
+
this.log("debug", "\u2705 Token found in sessionStorage");
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
const authCookie = this.getAuthCookie();
|
|
515
|
+
if (authCookie) {
|
|
516
|
+
try {
|
|
517
|
+
if (authCookie.includes(".")) {
|
|
518
|
+
const payload = JSON.parse(atob(authCookie.split(".")[1]));
|
|
519
|
+
if (payload.exp && Date.now() >= payload.exp * 1e3) {
|
|
520
|
+
this.log("debug", "Cookie token expired, removing...");
|
|
521
|
+
this.removeAuthCookie();
|
|
522
|
+
return false;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
this.log("debug", "\u2705 Valid token found in cookies");
|
|
526
|
+
return true;
|
|
527
|
+
} catch (error) {
|
|
528
|
+
this.log("warn", "Cookie token validation failed:", error);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
if (window.user || window.isAuthenticated) {
|
|
532
|
+
this.log("debug", "\u2705 Global authentication variable found");
|
|
533
|
+
return true;
|
|
534
|
+
}
|
|
535
|
+
this.log("debug", "\u274C No valid authentication found");
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* 공개 라우트인지 확인
|
|
540
|
+
*/
|
|
541
|
+
isPublicRoute(routeName) {
|
|
542
|
+
return this.config.publicRoutes.includes(routeName);
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* 보호된 라우트인지 확인
|
|
546
|
+
*/
|
|
547
|
+
isProtectedRoute(routeName) {
|
|
548
|
+
if (this.config.protectedRoutes.includes(routeName)) {
|
|
549
|
+
return true;
|
|
550
|
+
}
|
|
551
|
+
for (const prefix of this.config.protectedPrefixes) {
|
|
552
|
+
if (routeName.startsWith(prefix)) {
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* 인증 쿠키 가져오기
|
|
560
|
+
*/
|
|
561
|
+
getAuthCookie() {
|
|
562
|
+
const primaryCookie = this.getCookieValue(this.config.authCookieName);
|
|
563
|
+
if (primaryCookie) {
|
|
564
|
+
return primaryCookie;
|
|
565
|
+
}
|
|
566
|
+
for (const cookieName of this.config.authFallbackCookieNames) {
|
|
567
|
+
const cookieValue = this.getCookieValue(cookieName);
|
|
568
|
+
if (cookieValue) {
|
|
569
|
+
this.log("debug", `Found auth token in fallback cookie: ${cookieName}`);
|
|
570
|
+
return cookieValue;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* 쿠키 값 가져오기
|
|
577
|
+
*/
|
|
578
|
+
getCookieValue(name) {
|
|
579
|
+
const value = `; ${document.cookie}`;
|
|
580
|
+
const parts = value.split(`; ${name}=`);
|
|
581
|
+
if (parts.length === 2) {
|
|
582
|
+
return decodeURIComponent(parts.pop().split(";").shift());
|
|
583
|
+
}
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* 인증 쿠키 제거
|
|
588
|
+
*/
|
|
589
|
+
removeAuthCookie() {
|
|
590
|
+
const cookiesToRemove = [this.config.authCookieName, ...this.config.authFallbackCookieNames];
|
|
591
|
+
cookiesToRemove.forEach((cookieName) => {
|
|
592
|
+
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
|
|
593
|
+
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${window.location.pathname};`;
|
|
594
|
+
});
|
|
595
|
+
this.log("debug", "Auth cookies removed");
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* 액세스 토큰 가져오기
|
|
599
|
+
*/
|
|
600
|
+
getAccessToken() {
|
|
601
|
+
let token = localStorage.getItem("authToken") || localStorage.getItem("accessToken");
|
|
602
|
+
if (token) return token;
|
|
603
|
+
token = sessionStorage.getItem("authToken") || sessionStorage.getItem("accessToken");
|
|
604
|
+
if (token) return token;
|
|
605
|
+
token = this.getAuthCookie();
|
|
606
|
+
if (token) return token;
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* 액세스 토큰 설정
|
|
611
|
+
*/
|
|
612
|
+
setAccessToken(token, options = {}) {
|
|
613
|
+
if (!token) {
|
|
614
|
+
this.log("warn", "Empty token provided");
|
|
615
|
+
return false;
|
|
616
|
+
}
|
|
617
|
+
const {
|
|
618
|
+
storage = this.config.authStorage,
|
|
619
|
+
cookieOptions = this.config.authCookieOptions,
|
|
620
|
+
skipValidation = this.config.authSkipValidation
|
|
621
|
+
} = options;
|
|
622
|
+
try {
|
|
623
|
+
if (!skipValidation && token.includes(".")) {
|
|
624
|
+
try {
|
|
625
|
+
const payload = JSON.parse(atob(token.split(".")[1]));
|
|
626
|
+
if (payload.exp && Date.now() >= payload.exp * 1e3) {
|
|
627
|
+
this.log("warn", "\u274C Token is expired");
|
|
628
|
+
return false;
|
|
629
|
+
}
|
|
630
|
+
this.log("debug", "\u2705 JWT token validated");
|
|
631
|
+
} catch (error) {
|
|
632
|
+
this.log("warn", "\u26A0\uFE0F JWT validation failed, but proceeding:", error.message);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
switch (storage) {
|
|
636
|
+
case "localStorage":
|
|
637
|
+
localStorage.setItem("authToken", token);
|
|
638
|
+
this.log("debug", "Token saved to localStorage");
|
|
639
|
+
break;
|
|
640
|
+
case "sessionStorage":
|
|
641
|
+
sessionStorage.setItem("authToken", token);
|
|
642
|
+
this.log("debug", "Token saved to sessionStorage");
|
|
643
|
+
break;
|
|
644
|
+
case "cookie":
|
|
645
|
+
this.setAuthCookie(token, cookieOptions);
|
|
646
|
+
break;
|
|
647
|
+
default:
|
|
648
|
+
localStorage.setItem("authToken", token);
|
|
649
|
+
this.log("debug", "Token saved to localStorage (default)");
|
|
650
|
+
}
|
|
651
|
+
this.emitAuthEvent("token_set", {
|
|
652
|
+
storage,
|
|
653
|
+
tokenLength: token.length,
|
|
654
|
+
hasExpiration: token.includes(".")
|
|
655
|
+
});
|
|
656
|
+
return true;
|
|
657
|
+
} catch (error) {
|
|
658
|
+
this.log("Failed to set token:", error);
|
|
659
|
+
return false;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* 인증 쿠키 설정
|
|
664
|
+
*/
|
|
665
|
+
setAuthCookie(token, options = {}) {
|
|
666
|
+
const {
|
|
667
|
+
cookieName = this.config.authCookieName,
|
|
668
|
+
secure = window.location.protocol === "https:",
|
|
669
|
+
sameSite = "Strict",
|
|
670
|
+
path = "/",
|
|
671
|
+
domain = null
|
|
672
|
+
} = options;
|
|
673
|
+
let cookieString = `${cookieName}=${encodeURIComponent(token)}; path=${path}`;
|
|
674
|
+
if (secure) {
|
|
675
|
+
cookieString += "; Secure";
|
|
676
|
+
}
|
|
677
|
+
if (sameSite) {
|
|
678
|
+
cookieString += `; SameSite=${sameSite}`;
|
|
679
|
+
}
|
|
680
|
+
if (domain) {
|
|
681
|
+
cookieString += `; Domain=${domain}`;
|
|
682
|
+
}
|
|
683
|
+
try {
|
|
684
|
+
if (token.includes(".")) {
|
|
685
|
+
try {
|
|
686
|
+
const payload = JSON.parse(atob(token.split(".")[1]));
|
|
687
|
+
if (payload.exp) {
|
|
688
|
+
const expireDate = new Date(payload.exp * 1e3);
|
|
689
|
+
cookieString += `; Expires=${expireDate.toUTCString()}`;
|
|
690
|
+
}
|
|
691
|
+
} catch (error) {
|
|
692
|
+
this.log("Could not extract expiration from JWT token");
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
} catch (error) {
|
|
696
|
+
this.log("Token processing error:", error);
|
|
697
|
+
}
|
|
698
|
+
document.cookie = cookieString;
|
|
699
|
+
this.log(`Auth cookie set: ${cookieName}`);
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* 토큰 제거
|
|
703
|
+
*/
|
|
704
|
+
removeAccessToken(storage = "all") {
|
|
705
|
+
switch (storage) {
|
|
706
|
+
case "localStorage":
|
|
707
|
+
localStorage.removeItem("authToken");
|
|
708
|
+
localStorage.removeItem("accessToken");
|
|
709
|
+
break;
|
|
710
|
+
case "sessionStorage":
|
|
711
|
+
sessionStorage.removeItem("authToken");
|
|
712
|
+
sessionStorage.removeItem("accessToken");
|
|
713
|
+
break;
|
|
714
|
+
case "cookie":
|
|
715
|
+
this.removeAuthCookie();
|
|
716
|
+
break;
|
|
717
|
+
case "all":
|
|
718
|
+
default:
|
|
719
|
+
localStorage.removeItem("authToken");
|
|
720
|
+
localStorage.removeItem("accessToken");
|
|
721
|
+
sessionStorage.removeItem("authToken");
|
|
722
|
+
sessionStorage.removeItem("accessToken");
|
|
723
|
+
this.removeAuthCookie();
|
|
724
|
+
break;
|
|
725
|
+
}
|
|
726
|
+
this.emitAuthEvent("token_removed", { storage });
|
|
727
|
+
this.log(`Token removed from: ${storage}`);
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* 로그인 성공 처리
|
|
731
|
+
*/
|
|
732
|
+
handleLoginSuccess(targetRoute = null) {
|
|
733
|
+
const redirectRoute = targetRoute || this.config.redirectAfterLogin;
|
|
734
|
+
this.log(`\u{1F389} Login success, redirecting to: ${redirectRoute}`);
|
|
735
|
+
this.emitAuthEvent("login_success", { targetRoute: redirectRoute });
|
|
736
|
+
if (this.router && typeof this.router.navigateTo === "function") {
|
|
737
|
+
this.router.navigateTo(redirectRoute);
|
|
738
|
+
}
|
|
739
|
+
return redirectRoute;
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* 로그아웃 처리
|
|
743
|
+
*/
|
|
744
|
+
handleLogout() {
|
|
745
|
+
this.log("\u{1F44B} Logging out user");
|
|
746
|
+
this.removeAccessToken();
|
|
747
|
+
if (window.user) window.user = null;
|
|
748
|
+
if (window.isAuthenticated) window.isAuthenticated = false;
|
|
749
|
+
this.emitAuthEvent("logout", {});
|
|
750
|
+
if (this.router && typeof this.router.navigateTo === "function") {
|
|
751
|
+
this.router.navigateTo(this.config.loginRoute);
|
|
752
|
+
}
|
|
753
|
+
return this.config.loginRoute;
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* 인증 이벤트 발생
|
|
757
|
+
*/
|
|
758
|
+
emitAuthEvent(eventType, data) {
|
|
759
|
+
const event = new CustomEvent("router:auth", {
|
|
760
|
+
detail: {
|
|
761
|
+
type: eventType,
|
|
762
|
+
timestamp: Date.now(),
|
|
763
|
+
...data
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
document.dispatchEvent(event);
|
|
767
|
+
if (this.eventListeners.has(eventType)) {
|
|
768
|
+
this.eventListeners.get(eventType).forEach((listener) => {
|
|
769
|
+
try {
|
|
770
|
+
listener(data);
|
|
771
|
+
} catch (error) {
|
|
772
|
+
this.log("Event listener error:", error);
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
this.log(`\u{1F514} Auth event emitted: ${eventType}`, data);
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* 이벤트 리스너 등록
|
|
780
|
+
*/
|
|
781
|
+
on(eventType, listener) {
|
|
782
|
+
if (!this.eventListeners.has(eventType)) {
|
|
783
|
+
this.eventListeners.set(eventType, []);
|
|
784
|
+
}
|
|
785
|
+
this.eventListeners.get(eventType).push(listener);
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* 이벤트 리스너 제거
|
|
789
|
+
*/
|
|
790
|
+
off(eventType, listener) {
|
|
791
|
+
if (this.eventListeners.has(eventType)) {
|
|
792
|
+
const listeners = this.eventListeners.get(eventType);
|
|
793
|
+
const index = listeners.indexOf(listener);
|
|
794
|
+
if (index > -1) {
|
|
795
|
+
listeners.splice(index, 1);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* 인증 상태 통계
|
|
801
|
+
*/
|
|
802
|
+
getAuthStats() {
|
|
803
|
+
return {
|
|
804
|
+
enabled: this.config.enabled,
|
|
805
|
+
isAuthenticated: this.isUserAuthenticated(),
|
|
806
|
+
hasToken: !!this.getAccessToken(),
|
|
807
|
+
protectedRoutesCount: this.config.protectedRoutes.length,
|
|
808
|
+
protectedPrefixesCount: this.config.protectedPrefixes.length,
|
|
809
|
+
publicRoutesCount: this.config.publicRoutes.length,
|
|
810
|
+
storage: this.config.authStorage,
|
|
811
|
+
loginRoute: this.config.loginRoute
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* 정리 (메모리 누수 방지)
|
|
816
|
+
*/
|
|
817
|
+
destroy() {
|
|
818
|
+
this.eventListeners.clear();
|
|
819
|
+
this.log("debug", "AuthManager destroyed");
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
// src/plugins/CacheManager.js
|
|
824
|
+
var CacheManager = class {
|
|
825
|
+
constructor(router, options = {}) {
|
|
826
|
+
this.config = {
|
|
827
|
+
cacheMode: options.cacheMode || "memory",
|
|
828
|
+
// 'memory' 또는 'lru'
|
|
829
|
+
cacheTTL: options.cacheTTL || 3e5,
|
|
830
|
+
// 5분 (밀리초)
|
|
831
|
+
maxCacheSize: options.maxCacheSize || 50,
|
|
832
|
+
// LRU 캐시 최대 크기
|
|
833
|
+
debug: options.debug || false
|
|
834
|
+
};
|
|
835
|
+
this.router = router;
|
|
836
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
837
|
+
this.cacheTimestamps = /* @__PURE__ */ new Map();
|
|
838
|
+
this.lruOrder = [];
|
|
839
|
+
this.log("info", "CacheManager initialized with config:", this.config);
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* 로깅 래퍼 메서드
|
|
843
|
+
*/
|
|
844
|
+
log(level, ...args) {
|
|
845
|
+
if (this.router?.errorHandler) {
|
|
846
|
+
this.router.errorHandler.log(level, "CacheManager", ...args);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* 캐시에 값 저장
|
|
851
|
+
*/
|
|
852
|
+
setCache(key, value) {
|
|
853
|
+
const now = Date.now();
|
|
854
|
+
if (this.config.cacheMode === "lru") {
|
|
855
|
+
if (this.cache.size >= this.config.maxCacheSize && !this.cache.has(key)) {
|
|
856
|
+
const oldestKey = this.lruOrder.shift();
|
|
857
|
+
if (oldestKey) {
|
|
858
|
+
this.cache.delete(oldestKey);
|
|
859
|
+
this.cacheTimestamps.delete(oldestKey);
|
|
860
|
+
this.log("debug", `\u{1F5D1}\uFE0F LRU evicted cache key: ${oldestKey}`);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
const existingIndex = this.lruOrder.indexOf(key);
|
|
864
|
+
if (existingIndex > -1) {
|
|
865
|
+
this.lruOrder.splice(existingIndex, 1);
|
|
866
|
+
}
|
|
867
|
+
this.lruOrder.push(key);
|
|
868
|
+
}
|
|
869
|
+
this.cache.set(key, value);
|
|
870
|
+
this.cacheTimestamps.set(key, now);
|
|
871
|
+
this.log("debug", `\u{1F4BE} Cached: ${key} (size: ${this.cache.size})`);
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* 캐시에서 값 가져오기
|
|
875
|
+
*/
|
|
876
|
+
getFromCache(key) {
|
|
877
|
+
const now = Date.now();
|
|
878
|
+
const timestamp = this.cacheTimestamps.get(key);
|
|
879
|
+
if (timestamp && now - timestamp > this.config.cacheTTL) {
|
|
880
|
+
this.cache.delete(key);
|
|
881
|
+
this.cacheTimestamps.delete(key);
|
|
882
|
+
if (this.config.cacheMode === "lru") {
|
|
883
|
+
const index = this.lruOrder.indexOf(key);
|
|
884
|
+
if (index > -1) {
|
|
885
|
+
this.lruOrder.splice(index, 1);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
this.log("debug", `\u23F0 Cache expired and removed: ${key}`);
|
|
889
|
+
return null;
|
|
890
|
+
}
|
|
891
|
+
const value = this.cache.get(key);
|
|
892
|
+
if (value && this.config.cacheMode === "lru") {
|
|
893
|
+
const index = this.lruOrder.indexOf(key);
|
|
894
|
+
if (index > -1) {
|
|
895
|
+
this.lruOrder.splice(index, 1);
|
|
896
|
+
this.lruOrder.push(key);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
if (value) {
|
|
900
|
+
this.log("debug", `\u{1F3AF} Cache hit: ${key}`);
|
|
901
|
+
} else {
|
|
902
|
+
this.log("debug", `\u274C Cache miss: ${key}`);
|
|
903
|
+
}
|
|
904
|
+
return value;
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* 캐시에 키가 있는지 확인
|
|
908
|
+
*/
|
|
909
|
+
hasCache(key) {
|
|
910
|
+
return this.cache.has(key) && this.getFromCache(key) !== null;
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* 특정 키 패턴의 캐시 삭제
|
|
914
|
+
*/
|
|
915
|
+
invalidateByPattern(pattern) {
|
|
916
|
+
const keysToDelete = [];
|
|
917
|
+
for (const key of this.cache.keys()) {
|
|
918
|
+
if (key.includes(pattern) || key.startsWith(pattern)) {
|
|
919
|
+
keysToDelete.push(key);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
keysToDelete.forEach((key) => {
|
|
923
|
+
this.cache.delete(key);
|
|
924
|
+
this.cacheTimestamps.delete(key);
|
|
925
|
+
if (this.config.cacheMode === "lru") {
|
|
926
|
+
const index = this.lruOrder.indexOf(key);
|
|
927
|
+
if (index > -1) {
|
|
928
|
+
this.lruOrder.splice(index, 1);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
});
|
|
932
|
+
this.log("debug", `\u{1F9F9} Invalidated ${keysToDelete.length} cache entries matching: ${pattern}`);
|
|
933
|
+
return keysToDelete.length;
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* 특정 컴포넌트 캐시 무효화
|
|
937
|
+
*/
|
|
938
|
+
invalidateComponentCache(routeName) {
|
|
939
|
+
const patterns = [
|
|
940
|
+
`component_${routeName}`,
|
|
941
|
+
`script_${routeName}`,
|
|
942
|
+
`template_${routeName}`,
|
|
943
|
+
`style_${routeName}`,
|
|
944
|
+
`layout_${routeName}`
|
|
945
|
+
];
|
|
946
|
+
let totalInvalidated = 0;
|
|
947
|
+
patterns.forEach((pattern) => {
|
|
948
|
+
totalInvalidated += this.invalidateByPattern(pattern);
|
|
949
|
+
});
|
|
950
|
+
this.log(`\u{1F504} Invalidated component cache for route: ${routeName} (${totalInvalidated} entries)`);
|
|
951
|
+
return totalInvalidated;
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* 모든 컴포넌트 캐시 삭제
|
|
955
|
+
*/
|
|
956
|
+
clearComponentCache() {
|
|
957
|
+
const componentPatterns = ["component_", "script_", "template_", "style_", "layout_"];
|
|
958
|
+
let totalCleared = 0;
|
|
959
|
+
componentPatterns.forEach((pattern) => {
|
|
960
|
+
totalCleared += this.invalidateByPattern(pattern);
|
|
961
|
+
});
|
|
962
|
+
this.log(`\u{1F9FD} Cleared all component caches (${totalCleared} entries)`);
|
|
963
|
+
return totalCleared;
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* 전체 캐시 삭제
|
|
967
|
+
*/
|
|
968
|
+
clearCache() {
|
|
969
|
+
const size = this.cache.size;
|
|
970
|
+
this.cache.clear();
|
|
971
|
+
this.cacheTimestamps.clear();
|
|
972
|
+
this.lruOrder = [];
|
|
973
|
+
this.log(`\u{1F525} Cleared all cache (${size} entries)`);
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* 만료된 캐시 항목들 정리
|
|
977
|
+
*/
|
|
978
|
+
cleanExpiredCache() {
|
|
979
|
+
const now = Date.now();
|
|
980
|
+
const expiredKeys = [];
|
|
981
|
+
for (const [key, timestamp] of this.cacheTimestamps.entries()) {
|
|
982
|
+
if (now - timestamp > this.config.cacheTTL) {
|
|
983
|
+
expiredKeys.push(key);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
expiredKeys.forEach((key) => {
|
|
987
|
+
this.cache.delete(key);
|
|
988
|
+
this.cacheTimestamps.delete(key);
|
|
989
|
+
if (this.config.cacheMode === "lru") {
|
|
990
|
+
const index = this.lruOrder.indexOf(key);
|
|
991
|
+
if (index > -1) {
|
|
992
|
+
this.lruOrder.splice(index, 1);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
});
|
|
996
|
+
if (expiredKeys.length > 0) {
|
|
997
|
+
this.log(`\u23F1\uFE0F Cleaned ${expiredKeys.length} expired cache entries`);
|
|
998
|
+
}
|
|
999
|
+
return expiredKeys.length;
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* 캐시 통계 정보
|
|
1003
|
+
*/
|
|
1004
|
+
getCacheStats() {
|
|
1005
|
+
return {
|
|
1006
|
+
size: this.cache.size,
|
|
1007
|
+
maxSize: this.config.maxCacheSize,
|
|
1008
|
+
mode: this.config.cacheMode,
|
|
1009
|
+
ttl: this.config.cacheTTL,
|
|
1010
|
+
memoryUsage: this.getMemoryUsage(),
|
|
1011
|
+
hitRatio: this.getHitRatio(),
|
|
1012
|
+
categories: this.getCategorizedStats()
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* 메모리 사용량 추정
|
|
1017
|
+
*/
|
|
1018
|
+
getMemoryUsage() {
|
|
1019
|
+
let estimatedBytes = 0;
|
|
1020
|
+
for (const [key, value] of this.cache.entries()) {
|
|
1021
|
+
estimatedBytes += key.length * 2;
|
|
1022
|
+
if (typeof value === "string") {
|
|
1023
|
+
estimatedBytes += value.length * 2;
|
|
1024
|
+
} else if (typeof value === "object" && value !== null) {
|
|
1025
|
+
estimatedBytes += JSON.stringify(value).length * 2;
|
|
1026
|
+
} else {
|
|
1027
|
+
estimatedBytes += 8;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
return {
|
|
1031
|
+
bytes: estimatedBytes,
|
|
1032
|
+
kb: Math.round(estimatedBytes / 1024 * 100) / 100,
|
|
1033
|
+
mb: Math.round(estimatedBytes / (1024 * 1024) * 100) / 100
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* 히트 비율 계산 (간단한 추정)
|
|
1038
|
+
*/
|
|
1039
|
+
getHitRatio() {
|
|
1040
|
+
const ratio = this.cache.size > 0 ? Math.min(this.cache.size / this.config.maxCacheSize, 1) : 0;
|
|
1041
|
+
return Math.round(ratio * 100);
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* 카테고리별 캐시 통계
|
|
1045
|
+
*/
|
|
1046
|
+
getCategorizedStats() {
|
|
1047
|
+
const categories = {
|
|
1048
|
+
components: 0,
|
|
1049
|
+
scripts: 0,
|
|
1050
|
+
templates: 0,
|
|
1051
|
+
styles: 0,
|
|
1052
|
+
layouts: 0,
|
|
1053
|
+
others: 0
|
|
1054
|
+
};
|
|
1055
|
+
for (const key of this.cache.keys()) {
|
|
1056
|
+
if (key.startsWith("component_")) categories.components++;
|
|
1057
|
+
else if (key.startsWith("script_")) categories.scripts++;
|
|
1058
|
+
else if (key.startsWith("template_")) categories.templates++;
|
|
1059
|
+
else if (key.startsWith("style_")) categories.styles++;
|
|
1060
|
+
else if (key.startsWith("layout_")) categories.layouts++;
|
|
1061
|
+
else categories.others++;
|
|
1062
|
+
}
|
|
1063
|
+
return categories;
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* 캐시 키 목록 반환
|
|
1067
|
+
*/
|
|
1068
|
+
getCacheKeys() {
|
|
1069
|
+
return Array.from(this.cache.keys());
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* 특정 패턴의 캐시 키들 반환
|
|
1073
|
+
*/
|
|
1074
|
+
getCacheKeysByPattern(pattern) {
|
|
1075
|
+
return this.getCacheKeys().filter(
|
|
1076
|
+
(key) => key.includes(pattern) || key.startsWith(pattern)
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* 자동 정리 시작 (백그라운드에서 만료된 캐시 정리)
|
|
1081
|
+
*/
|
|
1082
|
+
startAutoCleanup(interval = 6e4) {
|
|
1083
|
+
if (this.cleanupInterval) {
|
|
1084
|
+
clearInterval(this.cleanupInterval);
|
|
1085
|
+
}
|
|
1086
|
+
this.cleanupInterval = setInterval(() => {
|
|
1087
|
+
this.cleanExpiredCache();
|
|
1088
|
+
}, interval);
|
|
1089
|
+
this.log(`\u{1F916} Auto cleanup started (interval: ${interval}ms)`);
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* 자동 정리 중지
|
|
1093
|
+
*/
|
|
1094
|
+
stopAutoCleanup() {
|
|
1095
|
+
if (this.cleanupInterval) {
|
|
1096
|
+
clearInterval(this.cleanupInterval);
|
|
1097
|
+
this.cleanupInterval = null;
|
|
1098
|
+
this.log("debug", "\u{1F6D1} Auto cleanup stopped");
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* 정리 (메모리 누수 방지)
|
|
1103
|
+
*/
|
|
1104
|
+
destroy() {
|
|
1105
|
+
this.stopAutoCleanup();
|
|
1106
|
+
this.clearCache();
|
|
1107
|
+
this.log("debug", "CacheManager destroyed");
|
|
1108
|
+
}
|
|
1109
|
+
};
|
|
1110
|
+
|
|
1111
|
+
// src/plugins/QueryManager.js
|
|
1112
|
+
var QueryManager = class {
|
|
1113
|
+
constructor(router, options = {}) {
|
|
1114
|
+
this.config = {
|
|
1115
|
+
enableParameterValidation: options.enableParameterValidation !== false,
|
|
1116
|
+
logSecurityWarnings: options.logSecurityWarnings !== false,
|
|
1117
|
+
maxParameterLength: options.maxParameterLength || 1e3,
|
|
1118
|
+
maxArraySize: options.maxArraySize || 100,
|
|
1119
|
+
maxParameterCount: options.maxParameterCount || 50,
|
|
1120
|
+
allowedKeyPattern: options.allowedKeyPattern || /^[a-zA-Z0-9_\-]+$/,
|
|
1121
|
+
debug: options.debug || false
|
|
1122
|
+
};
|
|
1123
|
+
this.router = router;
|
|
1124
|
+
this.currentQueryParams = {};
|
|
1125
|
+
this.log("info", "QueryManager initialized with config:", this.config);
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* 로깅 래퍼 메서드
|
|
1129
|
+
*/
|
|
1130
|
+
log(level, ...args) {
|
|
1131
|
+
if (this.router?.errorHandler) {
|
|
1132
|
+
this.router.errorHandler.log(level, "QueryManager", ...args);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* 파라미터 값 sanitize (XSS, SQL Injection 방어)
|
|
1137
|
+
*/
|
|
1138
|
+
sanitizeParameter(value) {
|
|
1139
|
+
if (typeof value !== "string") return value;
|
|
1140
|
+
let sanitized = value.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "").replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, "").replace(/<object\b[^<]*(?:(?!<\/object>)<[^<]*)*<\/object>/gi, "").replace(/<embed\b[^<]*(?:(?!<\/embed>)<[^<]*)*<\/embed>/gi, "").replace(/<link\b[^<]*>/gi, "").replace(/<meta\b[^<]*>/gi, "").replace(/javascript:/gi, "").replace(/vbscript:/gi, "").replace(/data:/gi, "").replace(/on\w+\s*=/gi, "").replace(/expression\s*\(/gi, "").replace(/url\s*\(/gi, "");
|
|
1141
|
+
const sqlPatterns = [
|
|
1142
|
+
/(\b(union|select|insert|update|delete|drop|create|alter|exec|execute|sp_|xp_)\b)/gi,
|
|
1143
|
+
/(;|\||&|\*|%|<|>)/g,
|
|
1144
|
+
// 위험한 특수문자
|
|
1145
|
+
/(--|\/\*|\*\/)/g,
|
|
1146
|
+
// SQL 주석
|
|
1147
|
+
/(\bor\b.*\b=\b|\band\b.*\b=\b)/gi,
|
|
1148
|
+
// OR/AND 조건문
|
|
1149
|
+
/('.*'|".*")/g,
|
|
1150
|
+
// 따옴표로 둘러싸인 문자열
|
|
1151
|
+
/(\\\w+)/g
|
|
1152
|
+
// 백슬래시 이스케이프
|
|
1153
|
+
];
|
|
1154
|
+
for (const pattern of sqlPatterns) {
|
|
1155
|
+
sanitized = sanitized.replace(pattern, "");
|
|
1156
|
+
}
|
|
1157
|
+
sanitized = sanitized.replace(/[<>'"&]{2,}/g, "");
|
|
1158
|
+
if (sanitized.length > this.config.maxParameterLength) {
|
|
1159
|
+
sanitized = sanitized.substring(0, this.config.maxParameterLength);
|
|
1160
|
+
}
|
|
1161
|
+
return sanitized.trim();
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* 파라미터 유효성 검증
|
|
1165
|
+
*/
|
|
1166
|
+
validateParameter(key, value) {
|
|
1167
|
+
if (!this.config.enableParameterValidation) {
|
|
1168
|
+
return true;
|
|
1169
|
+
}
|
|
1170
|
+
if (typeof key !== "string" || key.length === 0) {
|
|
1171
|
+
return false;
|
|
1172
|
+
}
|
|
1173
|
+
if (!this.config.allowedKeyPattern.test(key)) {
|
|
1174
|
+
if (this.config.logSecurityWarnings) {
|
|
1175
|
+
console.warn(`Invalid parameter key format: ${key}`);
|
|
1176
|
+
}
|
|
1177
|
+
return false;
|
|
1178
|
+
}
|
|
1179
|
+
if (key.length > 50) {
|
|
1180
|
+
if (this.config.logSecurityWarnings) {
|
|
1181
|
+
console.warn(`Parameter key too long: ${key}`);
|
|
1182
|
+
}
|
|
1183
|
+
return false;
|
|
1184
|
+
}
|
|
1185
|
+
if (value !== null && value !== void 0) {
|
|
1186
|
+
if (typeof value === "string") {
|
|
1187
|
+
if (value.length > this.config.maxParameterLength) {
|
|
1188
|
+
if (this.config.logSecurityWarnings) {
|
|
1189
|
+
console.warn(`Parameter value too long for key: ${key}`);
|
|
1190
|
+
}
|
|
1191
|
+
return false;
|
|
1192
|
+
}
|
|
1193
|
+
const dangerousPatterns = [
|
|
1194
|
+
/<script|<iframe|<object|<embed/gi,
|
|
1195
|
+
/javascript:|vbscript:|data:/gi,
|
|
1196
|
+
/union.*select|insert.*into|delete.*from/gi,
|
|
1197
|
+
/\.\.\//g,
|
|
1198
|
+
// 경로 탐색 공격
|
|
1199
|
+
/[<>'"&]{3,}/g
|
|
1200
|
+
// 연속된 특수문자
|
|
1201
|
+
];
|
|
1202
|
+
for (const pattern of dangerousPatterns) {
|
|
1203
|
+
if (pattern.test(value)) {
|
|
1204
|
+
if (this.config.logSecurityWarnings) {
|
|
1205
|
+
console.warn(`Dangerous pattern detected in parameter ${key}:`, value);
|
|
1206
|
+
}
|
|
1207
|
+
return false;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
} else if (Array.isArray(value)) {
|
|
1211
|
+
if (value.length > this.config.maxArraySize) {
|
|
1212
|
+
if (this.config.logSecurityWarnings) {
|
|
1213
|
+
console.warn(`Parameter array too large for key: ${key}`);
|
|
1214
|
+
}
|
|
1215
|
+
return false;
|
|
1216
|
+
}
|
|
1217
|
+
for (const item of value) {
|
|
1218
|
+
if (!this.validateParameter(`${key}[]`, item)) {
|
|
1219
|
+
return false;
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
return true;
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* 쿼리스트링 파싱
|
|
1228
|
+
*/
|
|
1229
|
+
parseQueryString(queryString) {
|
|
1230
|
+
const params = {};
|
|
1231
|
+
if (!queryString) return params;
|
|
1232
|
+
const pairs = queryString.split("&");
|
|
1233
|
+
for (const pair of pairs) {
|
|
1234
|
+
try {
|
|
1235
|
+
const [rawKey, rawValue] = pair.split("=");
|
|
1236
|
+
if (!rawKey) continue;
|
|
1237
|
+
let key, value;
|
|
1238
|
+
try {
|
|
1239
|
+
key = decodeURIComponent(rawKey);
|
|
1240
|
+
value = rawValue ? decodeURIComponent(rawValue) : "";
|
|
1241
|
+
} catch (e) {
|
|
1242
|
+
this.log("warn", "Failed to decode URI component:", pair);
|
|
1243
|
+
continue;
|
|
1244
|
+
}
|
|
1245
|
+
if (!this.validateParameter(key, value)) {
|
|
1246
|
+
this.log("warn", `Parameter rejected by security filter: ${key}`);
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
const sanitizedValue = this.sanitizeParameter(value);
|
|
1250
|
+
if (key.endsWith("[]")) {
|
|
1251
|
+
const arrayKey = key.slice(0, -2);
|
|
1252
|
+
if (!this.validateParameter(arrayKey, [])) {
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
1255
|
+
if (!params[arrayKey]) params[arrayKey] = [];
|
|
1256
|
+
if (params[arrayKey].length < this.config.maxArraySize) {
|
|
1257
|
+
params[arrayKey].push(sanitizedValue);
|
|
1258
|
+
} else {
|
|
1259
|
+
if (this.config.logSecurityWarnings) {
|
|
1260
|
+
console.warn(`Array parameter ${arrayKey} size limit exceeded`);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
} else {
|
|
1264
|
+
params[key] = sanitizedValue;
|
|
1265
|
+
}
|
|
1266
|
+
} catch (error) {
|
|
1267
|
+
this.log("error", "Error parsing query parameter:", pair, error);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
const paramCount = Object.keys(params).length;
|
|
1271
|
+
if (paramCount > this.config.maxParameterCount) {
|
|
1272
|
+
if (this.config.logSecurityWarnings) {
|
|
1273
|
+
console.warn(`Too many parameters (${paramCount}). Limiting to first ${this.config.maxParameterCount}.`);
|
|
1274
|
+
}
|
|
1275
|
+
const limitedParams = {};
|
|
1276
|
+
let count = 0;
|
|
1277
|
+
for (const [key, value] of Object.entries(params)) {
|
|
1278
|
+
if (count >= this.config.maxParameterCount) break;
|
|
1279
|
+
limitedParams[key] = value;
|
|
1280
|
+
count++;
|
|
1281
|
+
}
|
|
1282
|
+
return limitedParams;
|
|
1283
|
+
}
|
|
1284
|
+
return params;
|
|
1285
|
+
}
|
|
1286
|
+
/**
|
|
1287
|
+
* 쿼리스트링 생성
|
|
1288
|
+
*/
|
|
1289
|
+
buildQueryString(params) {
|
|
1290
|
+
if (!params || Object.keys(params).length === 0) return "";
|
|
1291
|
+
const pairs = [];
|
|
1292
|
+
for (const [key, value] of Object.entries(params)) {
|
|
1293
|
+
if (Array.isArray(value)) {
|
|
1294
|
+
for (const item of value) {
|
|
1295
|
+
pairs.push(`${encodeURIComponent(key)}[]=${encodeURIComponent(item)}`);
|
|
1296
|
+
}
|
|
1297
|
+
} else if (value !== void 0 && value !== null) {
|
|
1298
|
+
pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
return pairs.join("&");
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* 쿼리 파라미터 변경 감지
|
|
1305
|
+
*/
|
|
1306
|
+
hasQueryParamsChanged(newParams) {
|
|
1307
|
+
if (!this.currentQueryParams && !newParams) return false;
|
|
1308
|
+
if (!this.currentQueryParams || !newParams) return true;
|
|
1309
|
+
const oldKeys = Object.keys(this.currentQueryParams);
|
|
1310
|
+
const newKeys = Object.keys(newParams);
|
|
1311
|
+
if (oldKeys.length !== newKeys.length) return true;
|
|
1312
|
+
for (const key of oldKeys) {
|
|
1313
|
+
if (JSON.stringify(this.currentQueryParams[key]) !== JSON.stringify(newParams[key])) {
|
|
1314
|
+
return true;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
return false;
|
|
1318
|
+
}
|
|
1319
|
+
/**
|
|
1320
|
+
* 현재 쿼리 파라미터 전체 가져오기
|
|
1321
|
+
*/
|
|
1322
|
+
getQueryParams() {
|
|
1323
|
+
return { ...this.currentQueryParams };
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* 특정 쿼리 파라미터 가져오기
|
|
1327
|
+
*/
|
|
1328
|
+
getQueryParam(key) {
|
|
1329
|
+
return this.currentQueryParams ? this.currentQueryParams[key] : void 0;
|
|
1330
|
+
}
|
|
1331
|
+
/**
|
|
1332
|
+
* 쿼리 파라미터 설정
|
|
1333
|
+
*/
|
|
1334
|
+
setQueryParams(params, replace = false) {
|
|
1335
|
+
if (!params || typeof params !== "object") {
|
|
1336
|
+
console.warn("Invalid parameters object provided to setQueryParams");
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
const currentParams = replace ? {} : { ...this.currentQueryParams };
|
|
1340
|
+
const sanitizedParams = {};
|
|
1341
|
+
for (const [key, value] of Object.entries(params)) {
|
|
1342
|
+
if (!this.validateParameter(key, value)) {
|
|
1343
|
+
console.warn(`Parameter ${key} rejected by security filter`);
|
|
1344
|
+
continue;
|
|
1345
|
+
}
|
|
1346
|
+
if (value !== void 0 && value !== null) {
|
|
1347
|
+
if (Array.isArray(value)) {
|
|
1348
|
+
sanitizedParams[key] = value.map((item) => this.sanitizeParameter(item));
|
|
1349
|
+
} else {
|
|
1350
|
+
sanitizedParams[key] = this.sanitizeParameter(value);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
Object.assign(currentParams, sanitizedParams);
|
|
1355
|
+
for (const [key, value] of Object.entries(currentParams)) {
|
|
1356
|
+
if (value === void 0 || value === null || value === "") {
|
|
1357
|
+
delete currentParams[key];
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
const paramCount = Object.keys(currentParams).length;
|
|
1361
|
+
if (paramCount > this.config.maxParameterCount) {
|
|
1362
|
+
if (this.config.logSecurityWarnings) {
|
|
1363
|
+
console.warn(`Too many parameters after update (${paramCount}). Some parameters may be dropped.`);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
this.currentQueryParams = currentParams;
|
|
1367
|
+
this.updateURL();
|
|
1368
|
+
}
|
|
1369
|
+
/**
|
|
1370
|
+
* 쿼리 파라미터 제거
|
|
1371
|
+
*/
|
|
1372
|
+
removeQueryParams(keys) {
|
|
1373
|
+
if (!keys) return;
|
|
1374
|
+
const keysToRemove = Array.isArray(keys) ? keys : [keys];
|
|
1375
|
+
for (const key of keysToRemove) {
|
|
1376
|
+
delete this.currentQueryParams[key];
|
|
1377
|
+
}
|
|
1378
|
+
this.updateURL();
|
|
1379
|
+
}
|
|
1380
|
+
/**
|
|
1381
|
+
* 쿼리 파라미터 초기화
|
|
1382
|
+
*/
|
|
1383
|
+
clearQueryParams() {
|
|
1384
|
+
this.currentQueryParams = {};
|
|
1385
|
+
this.updateURL();
|
|
1386
|
+
}
|
|
1387
|
+
/**
|
|
1388
|
+
* 현재 쿼리 파라미터 설정 (라우터에서 호출)
|
|
1389
|
+
*/
|
|
1390
|
+
setCurrentQueryParams(params) {
|
|
1391
|
+
this.currentQueryParams = params || {};
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* URL 업데이트 (라우터의 updateURL 메소드 호출)
|
|
1395
|
+
*/
|
|
1396
|
+
updateURL() {
|
|
1397
|
+
if (this.router && typeof this.router.updateURL === "function") {
|
|
1398
|
+
const route = this.router.currentHash || "home";
|
|
1399
|
+
this.router.updateURL(route, this.currentQueryParams);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
/**
|
|
1403
|
+
* 쿼리 파라미터 통계
|
|
1404
|
+
*/
|
|
1405
|
+
getStats() {
|
|
1406
|
+
return {
|
|
1407
|
+
currentParams: Object.keys(this.currentQueryParams).length,
|
|
1408
|
+
maxAllowed: this.config.maxParameterCount,
|
|
1409
|
+
validationEnabled: this.config.enableParameterValidation,
|
|
1410
|
+
currentQueryString: this.buildQueryString(this.currentQueryParams)
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* 정리 (메모리 누수 방지)
|
|
1415
|
+
*/
|
|
1416
|
+
destroy() {
|
|
1417
|
+
this.currentQueryParams = {};
|
|
1418
|
+
this.router = null;
|
|
1419
|
+
this.log("debug", "QueryManager destroyed");
|
|
1420
|
+
}
|
|
1421
|
+
};
|
|
1422
|
+
|
|
1423
|
+
// src/core/RouteLoader.js
|
|
1424
|
+
var RouteLoader = class {
|
|
1425
|
+
constructor(router, options = {}) {
|
|
1426
|
+
this.config = {
|
|
1427
|
+
basePath: options.basePath || "/src",
|
|
1428
|
+
routesPath: options.routesPath || "/routes",
|
|
1429
|
+
environment: options.environment || "development",
|
|
1430
|
+
useLayout: options.useLayout !== false,
|
|
1431
|
+
defaultLayout: options.defaultLayout || "default",
|
|
1432
|
+
useComponents: options.useComponents !== false,
|
|
1433
|
+
debug: options.debug || false
|
|
1434
|
+
};
|
|
1435
|
+
this.router = router;
|
|
1436
|
+
this.log("debug", "RouteLoader initialized with config:", this.config);
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* 스크립트 파일 로드
|
|
1440
|
+
*/
|
|
1441
|
+
async loadScript(routeName) {
|
|
1442
|
+
let script;
|
|
1443
|
+
try {
|
|
1444
|
+
if (this.config.environment === "production") {
|
|
1445
|
+
const importPath = `${this.config.routesPath}/${routeName}.js`;
|
|
1446
|
+
this.log("debug", `Loading production route: ${importPath}`);
|
|
1447
|
+
const module = await import(importPath);
|
|
1448
|
+
script = module.default;
|
|
1449
|
+
} else {
|
|
1450
|
+
const importPath = `${this.config.basePath}/logic/${routeName}.js`;
|
|
1451
|
+
this.log("debug", `Loading development route: ${importPath}`);
|
|
1452
|
+
const module = await import(importPath);
|
|
1453
|
+
script = module.default;
|
|
1454
|
+
}
|
|
1455
|
+
if (!script) {
|
|
1456
|
+
throw new Error(`Route '${routeName}' not found - no default export`);
|
|
1457
|
+
}
|
|
1458
|
+
} catch (error) {
|
|
1459
|
+
if (error.message.includes("Failed to resolve") || error.message.includes("Failed to fetch") || error.message.includes("not found") || error.name === "TypeError") {
|
|
1460
|
+
throw new Error(`Route '${routeName}' not found - 404`);
|
|
1461
|
+
}
|
|
1462
|
+
throw error;
|
|
1463
|
+
}
|
|
1464
|
+
return script;
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* 템플릿 파일 로드 (실패시 기본값 반환)
|
|
1468
|
+
*/
|
|
1469
|
+
async loadTemplate(routeName) {
|
|
1470
|
+
try {
|
|
1471
|
+
const response = await fetch(`${this.config.basePath}/views/${routeName}.html`);
|
|
1472
|
+
if (!response.ok) throw new Error(`Template not found: ${response.status}`);
|
|
1473
|
+
const template = await response.text();
|
|
1474
|
+
this.log("debug", `Template '${routeName}' loaded successfully`);
|
|
1475
|
+
return template;
|
|
1476
|
+
} catch (error) {
|
|
1477
|
+
this.log("warn", `Template '${routeName}' not found, using default:`, error.message);
|
|
1478
|
+
return this.generateDefaultTemplate(routeName);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
/**
|
|
1482
|
+
* 스타일 파일 로드 (실패시 빈 문자열 반환)
|
|
1483
|
+
*/
|
|
1484
|
+
async loadStyle(routeName) {
|
|
1485
|
+
try {
|
|
1486
|
+
const response = await fetch(`${this.config.basePath}/styles/${routeName}.css`);
|
|
1487
|
+
if (!response.ok) throw new Error(`Style not found: ${response.status}`);
|
|
1488
|
+
const style = await response.text();
|
|
1489
|
+
this.log("debug", `Style '${routeName}' loaded successfully`);
|
|
1490
|
+
return style;
|
|
1491
|
+
} catch (error) {
|
|
1492
|
+
this.log("debug", `Style '${routeName}' not found, no styles applied:`, error.message);
|
|
1493
|
+
return "";
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
/**
|
|
1497
|
+
* 레이아웃 파일 로드 (실패시 null 반환)
|
|
1498
|
+
*/
|
|
1499
|
+
async loadLayout(layoutName) {
|
|
1500
|
+
try {
|
|
1501
|
+
const response = await fetch(`${this.config.basePath}/layouts/${layoutName}.html`);
|
|
1502
|
+
if (!response.ok) throw new Error(`Layout not found: ${response.status}`);
|
|
1503
|
+
const layout = await response.text();
|
|
1504
|
+
this.log("debug", `Layout '${layoutName}' loaded successfully`);
|
|
1505
|
+
return layout;
|
|
1506
|
+
} catch (error) {
|
|
1507
|
+
this.log("debug", `Layout '${layoutName}' not found, no layout applied:`, error.message);
|
|
1508
|
+
return null;
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* 레이아웃과 템플릿 병합
|
|
1513
|
+
*/
|
|
1514
|
+
mergeLayoutWithTemplate(routeName, layout, template) {
|
|
1515
|
+
let result;
|
|
1516
|
+
if (layout.includes("{{ content }}")) {
|
|
1517
|
+
result = layout.replace(
|
|
1518
|
+
/{{ content }}/s,
|
|
1519
|
+
template
|
|
1520
|
+
);
|
|
1521
|
+
} else if (layout.includes('class="main-content"')) {
|
|
1522
|
+
this.log("debug", "Using main-content replacement");
|
|
1523
|
+
result = layout.replace(
|
|
1524
|
+
/(<div class="container">).*?(<\/div>\s*<\/main>)/s,
|
|
1525
|
+
`$1${template}$2`
|
|
1526
|
+
);
|
|
1527
|
+
} else {
|
|
1528
|
+
this.log("debug", "Wrapping template with layout");
|
|
1529
|
+
result = `${layout}
|
|
1530
|
+
${template}`;
|
|
1531
|
+
}
|
|
1532
|
+
return result;
|
|
1533
|
+
}
|
|
1534
|
+
/**
|
|
1535
|
+
* Vue 컴포넌트 생성
|
|
1536
|
+
*/
|
|
1537
|
+
async createVueComponent(routeName) {
|
|
1538
|
+
const cacheKey = `component_${routeName}`;
|
|
1539
|
+
const cached = this.router.cacheManager?.getFromCache(cacheKey);
|
|
1540
|
+
if (cached) {
|
|
1541
|
+
return cached;
|
|
1542
|
+
}
|
|
1543
|
+
const script = await this.loadScript(routeName);
|
|
1544
|
+
const router = this.router;
|
|
1545
|
+
const isProduction = this.config.environment === "production";
|
|
1546
|
+
let template, style = "", layout = null;
|
|
1547
|
+
if (isProduction) {
|
|
1548
|
+
template = script.template || this.generateDefaultTemplate(routeName);
|
|
1549
|
+
} else {
|
|
1550
|
+
template = await this.loadTemplate(routeName);
|
|
1551
|
+
style = await this.loadStyle(routeName);
|
|
1552
|
+
layout = this.config.useLayout && script.layout !== null ? await this.loadLayout(script.layout || this.config.defaultLayout) : null;
|
|
1553
|
+
if (layout) {
|
|
1554
|
+
template = this.mergeLayoutWithTemplate(routeName, layout, template);
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
const component = {
|
|
1558
|
+
...script,
|
|
1559
|
+
name: script.name || this.toPascalCase(routeName),
|
|
1560
|
+
template,
|
|
1561
|
+
components: this.config.useComponents && router.componentLoader ? await router.componentLoader.loadAllComponents() : {},
|
|
1562
|
+
data() {
|
|
1563
|
+
const originalData = script.data ? script.data() : {};
|
|
1564
|
+
const commonData = {
|
|
1565
|
+
...originalData,
|
|
1566
|
+
currentRoute: routeName,
|
|
1567
|
+
pageTitle: script.pageTitle || router.routeLoader.generatePageTitle(routeName),
|
|
1568
|
+
showHeader: script.showHeader !== false,
|
|
1569
|
+
headerTitle: script.headerTitle || router.routeLoader.generatePageTitle(routeName),
|
|
1570
|
+
headerSubtitle: script.headerSubtitle,
|
|
1571
|
+
$query: router.queryManager?.getQueryParams() || {},
|
|
1572
|
+
$lang: router.i18nManager?.getCurrentLanguage() || router.config.i18nDefaultLanguage,
|
|
1573
|
+
$dataLoading: false
|
|
1574
|
+
};
|
|
1575
|
+
return commonData;
|
|
1576
|
+
},
|
|
1577
|
+
computed: {
|
|
1578
|
+
...script.computed || {},
|
|
1579
|
+
params() {
|
|
1580
|
+
return router.queryManager?.getQueryParams() || {};
|
|
1581
|
+
}
|
|
1582
|
+
},
|
|
1583
|
+
async mounted() {
|
|
1584
|
+
if (script.mounted) {
|
|
1585
|
+
await script.mounted.call(this);
|
|
1586
|
+
}
|
|
1587
|
+
if (script.dataURL) {
|
|
1588
|
+
await this.$fetchData();
|
|
1589
|
+
}
|
|
1590
|
+
},
|
|
1591
|
+
methods: {
|
|
1592
|
+
...script.methods,
|
|
1593
|
+
// 라우팅 관련
|
|
1594
|
+
navigateTo: (route, params) => router.navigateTo(route, params),
|
|
1595
|
+
getCurrentRoute: () => router.getCurrentRoute(),
|
|
1596
|
+
getQueryParams: () => router.queryManager?.getQueryParams() || {},
|
|
1597
|
+
getQueryParam: (key) => router.queryManager?.getQueryParam(key),
|
|
1598
|
+
setQueryParams: (params, replace) => router.queryManager?.setQueryParams(params, replace),
|
|
1599
|
+
removeQueryParams: (keys) => router.queryManager?.removeQueryParams(keys),
|
|
1600
|
+
// i18n 관련
|
|
1601
|
+
$t: (key, params) => router.i18nManager?.t(key, params) || key,
|
|
1602
|
+
$i18n: () => router.i18nManager || null,
|
|
1603
|
+
// 인증 관련
|
|
1604
|
+
$isAuthenticated: () => router.authManager?.isUserAuthenticated() || false,
|
|
1605
|
+
$logout: () => router.authManager ? router.navigateTo(router.authManager.handleLogout()) : null,
|
|
1606
|
+
$loginSuccess: (target) => router.authManager ? router.navigateTo(router.authManager.handleLoginSuccess(target)) : null,
|
|
1607
|
+
$checkAuth: (route) => router.authManager ? router.authManager.checkAuthentication(route) : Promise.resolve({ allowed: true, reason: "auth_disabled" }),
|
|
1608
|
+
$getToken: () => router.authManager?.getAccessToken() || null,
|
|
1609
|
+
$setToken: (token, options) => router.authManager?.setAccessToken(token, options) || false,
|
|
1610
|
+
$removeToken: (storage) => router.authManager?.removeAccessToken(storage) || null,
|
|
1611
|
+
$getAuthCookie: () => router.authManager?.getAuthCookie() || null,
|
|
1612
|
+
$getCookie: (name) => router.authManager?.getCookieValue(name) || null,
|
|
1613
|
+
// 데이터 fetch
|
|
1614
|
+
async $fetchData() {
|
|
1615
|
+
if (!script.dataURL) return;
|
|
1616
|
+
this.$dataLoading = true;
|
|
1617
|
+
try {
|
|
1618
|
+
const data = await router.routeLoader.fetchComponentData(script.dataURL);
|
|
1619
|
+
if (router.errorHandler) router.errorHandler.debug("RouteLoader", `Data fetched for ${routeName}:`, data);
|
|
1620
|
+
Object.assign(this, data);
|
|
1621
|
+
this.$emit("data-loaded", data);
|
|
1622
|
+
} catch (error) {
|
|
1623
|
+
if (router.errorHandler) router.errorHandler.warn("RouteLoader", `Failed to fetch data for ${routeName}:`, error);
|
|
1624
|
+
this.$emit("data-error", error);
|
|
1625
|
+
} finally {
|
|
1626
|
+
this.$dataLoading = false;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
},
|
|
1630
|
+
_routeName: routeName
|
|
1631
|
+
};
|
|
1632
|
+
if (!isProduction && style) {
|
|
1633
|
+
component._style = style;
|
|
1634
|
+
}
|
|
1635
|
+
this.router.cacheManager?.setCache(cacheKey, component);
|
|
1636
|
+
return component;
|
|
1637
|
+
}
|
|
1638
|
+
/**
|
|
1639
|
+
* 문자열을 PascalCase로 변환
|
|
1640
|
+
*/
|
|
1641
|
+
toPascalCase(str) {
|
|
1642
|
+
return str.split(/[-_\s]+/).map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join("");
|
|
1643
|
+
}
|
|
1644
|
+
/**
|
|
1645
|
+
* 기본 템플릿 생성
|
|
1646
|
+
*/
|
|
1647
|
+
generateDefaultTemplate(routeName) {
|
|
1648
|
+
return `<div class="route-${routeName}"><h1>Route: ${routeName}</h1></div>`;
|
|
1649
|
+
}
|
|
1650
|
+
/**
|
|
1651
|
+
* 컴포넌트 데이터 가져오기
|
|
1652
|
+
*/
|
|
1653
|
+
async fetchComponentData(dataURL) {
|
|
1654
|
+
try {
|
|
1655
|
+
const queryString = this.router.queryManager?.buildQueryString(this.router.queryManager?.getQueryParams()) || "";
|
|
1656
|
+
const fullURL = queryString ? `${dataURL}?${queryString}` : dataURL;
|
|
1657
|
+
this.log("debug", `Fetching data from: ${fullURL}`);
|
|
1658
|
+
const response = await fetch(fullURL, {
|
|
1659
|
+
method: "GET",
|
|
1660
|
+
headers: {
|
|
1661
|
+
"Content-Type": "application/json",
|
|
1662
|
+
"Accept": "application/json"
|
|
1663
|
+
}
|
|
1664
|
+
});
|
|
1665
|
+
if (!response.ok) {
|
|
1666
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
1667
|
+
}
|
|
1668
|
+
const data = await response.json();
|
|
1669
|
+
if (typeof data !== "object" || data === null) {
|
|
1670
|
+
throw new Error("Invalid data format: expected object");
|
|
1671
|
+
}
|
|
1672
|
+
return data;
|
|
1673
|
+
} catch (error) {
|
|
1674
|
+
this.log("error", "Failed to fetch component data:", error);
|
|
1675
|
+
throw error;
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
/**
|
|
1679
|
+
* 캐시 무효화
|
|
1680
|
+
*/
|
|
1681
|
+
invalidateCache(routeName) {
|
|
1682
|
+
if (this.router.cacheManager) {
|
|
1683
|
+
this.router.cacheManager.invalidateComponentCache(routeName);
|
|
1684
|
+
}
|
|
1685
|
+
this.log("debug", `Cache invalidated for route: ${routeName}`);
|
|
1686
|
+
}
|
|
1687
|
+
/**
|
|
1688
|
+
* 통계 정보 반환
|
|
1689
|
+
*/
|
|
1690
|
+
getStats() {
|
|
1691
|
+
return {
|
|
1692
|
+
environment: this.config.environment,
|
|
1693
|
+
basePath: this.config.basePath,
|
|
1694
|
+
routesPath: this.config.routesPath,
|
|
1695
|
+
useLayout: this.config.useLayout,
|
|
1696
|
+
useComponents: this.config.useComponents
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
/**
|
|
1700
|
+
* 페이지 제목 생성
|
|
1701
|
+
*/
|
|
1702
|
+
generatePageTitle(routeName) {
|
|
1703
|
+
return this.toPascalCase(routeName).replace(/([A-Z])/g, " $1").trim();
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* 로깅 래퍼 메서드
|
|
1707
|
+
*/
|
|
1708
|
+
log(level, ...args) {
|
|
1709
|
+
if (this.router?.errorHandler) {
|
|
1710
|
+
this.router.errorHandler.log(level, "RouteLoader", ...args);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
/**
|
|
1714
|
+
* 정리 (메모리 누수 방지)
|
|
1715
|
+
*/
|
|
1716
|
+
destroy() {
|
|
1717
|
+
this.log("debug", "RouteLoader destroyed");
|
|
1718
|
+
this.router = null;
|
|
1719
|
+
}
|
|
1720
|
+
};
|
|
1721
|
+
|
|
1722
|
+
// src/core/ErrorHandler.js
|
|
1723
|
+
var ErrorHandler = class {
|
|
1724
|
+
constructor(router, options = {}) {
|
|
1725
|
+
this.config = {
|
|
1726
|
+
enableErrorReporting: options.enableErrorReporting !== false,
|
|
1727
|
+
debug: options.debug || false,
|
|
1728
|
+
logLevel: options.logLevel || (options.debug ? "debug" : "info"),
|
|
1729
|
+
environment: options.environment || "development"
|
|
1730
|
+
};
|
|
1731
|
+
this.router = router;
|
|
1732
|
+
this.logLevels = {
|
|
1733
|
+
error: 0,
|
|
1734
|
+
warn: 1,
|
|
1735
|
+
info: 2,
|
|
1736
|
+
debug: 3
|
|
1737
|
+
};
|
|
1738
|
+
this.log("info", "ErrorHandler", "ErrorHandler initialized with config:", this.config);
|
|
1739
|
+
}
|
|
1740
|
+
/**
|
|
1741
|
+
* 라우트 에러 처리
|
|
1742
|
+
*/
|
|
1743
|
+
async handleRouteError(routeName, error) {
|
|
1744
|
+
let errorCode = 500;
|
|
1745
|
+
let errorMessage = "\uD398\uC774\uC9C0\uB97C \uB85C\uB4DC\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.";
|
|
1746
|
+
this.debug("ErrorHandler", "\uC5D0\uB7EC \uC0C1\uC138:", error.message, error.name);
|
|
1747
|
+
if (error.message.includes("not found") || error.message.includes("404") || error.message.includes("Failed to resolve") || error.message.includes("Failed to fetch") || error.name === "TypeError" && error.message.includes("resolve")) {
|
|
1748
|
+
errorCode = 404;
|
|
1749
|
+
errorMessage = `'${routeName}' \uD398\uC774\uC9C0\uB97C \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.`;
|
|
1750
|
+
} else if (error.message.includes("network") && !error.message.includes("not found")) {
|
|
1751
|
+
errorCode = 503;
|
|
1752
|
+
errorMessage = "\uB124\uD2B8\uC6CC\uD06C \uC5F0\uACB0\uC744 \uD655\uC778\uD574 \uC8FC\uC138\uC694.";
|
|
1753
|
+
} else if (error.message.includes("permission") || error.message.includes("403")) {
|
|
1754
|
+
errorCode = 403;
|
|
1755
|
+
errorMessage = "\uD398\uC774\uC9C0\uC5D0 \uC811\uADFC\uD560 \uAD8C\uD55C\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.";
|
|
1756
|
+
}
|
|
1757
|
+
this.debug("ErrorHandler", `\uC5D0\uB7EC \uCF54\uB4DC \uACB0\uC815: ${errorCode} (\uB77C\uC6B0\uD2B8: ${routeName})`);
|
|
1758
|
+
if (this.config.enableErrorReporting) {
|
|
1759
|
+
this.reportError(routeName, error, errorCode);
|
|
1760
|
+
}
|
|
1761
|
+
try {
|
|
1762
|
+
if (errorCode === 404) {
|
|
1763
|
+
await this.load404Page();
|
|
1764
|
+
} else {
|
|
1765
|
+
await this.loadErrorPage(errorCode, errorMessage);
|
|
1766
|
+
}
|
|
1767
|
+
} catch (fallbackError) {
|
|
1768
|
+
this.error("ErrorHandler", "\uC5D0\uB7EC \uD398\uC774\uC9C0 \uB85C\uB529 \uC2E4\uD328:", fallbackError);
|
|
1769
|
+
this.showFallbackErrorPage(errorCode, errorMessage);
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
/**
|
|
1773
|
+
* 404 페이지 로딩
|
|
1774
|
+
*/
|
|
1775
|
+
async load404Page() {
|
|
1776
|
+
try {
|
|
1777
|
+
this.info("ErrorHandler", "Loading 404 page...");
|
|
1778
|
+
const component = await this.createVueComponent("404");
|
|
1779
|
+
await this.renderComponentWithTransition(component, "404");
|
|
1780
|
+
this.info("ErrorHandler", "404 page loaded successfully");
|
|
1781
|
+
} catch (error) {
|
|
1782
|
+
this.error("ErrorHandler", "404 page loading failed:", error);
|
|
1783
|
+
this.showFallbackErrorPage("404", "\uD398\uC774\uC9C0\uB97C \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.");
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
/**
|
|
1787
|
+
* 에러 페이지 로딩
|
|
1788
|
+
*/
|
|
1789
|
+
async loadErrorPage(errorCode, errorMessage) {
|
|
1790
|
+
try {
|
|
1791
|
+
this.info("ErrorHandler", `Loading error page for ${errorCode}...`);
|
|
1792
|
+
const errorComponent = await this.createErrorComponent(errorCode, errorMessage);
|
|
1793
|
+
await this.renderComponentWithTransition(errorComponent, "error");
|
|
1794
|
+
this.info("ErrorHandler", `Error page ${errorCode} loaded successfully`);
|
|
1795
|
+
} catch (error) {
|
|
1796
|
+
this.error("ErrorHandler", `Error page ${errorCode} loading failed:`, error);
|
|
1797
|
+
this.showFallbackErrorPage(errorCode, errorMessage);
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
/**
|
|
1801
|
+
* 에러 컴포넌트 생성
|
|
1802
|
+
*/
|
|
1803
|
+
async createErrorComponent(errorCode, errorMessage) {
|
|
1804
|
+
try {
|
|
1805
|
+
const component = await this.createVueComponent("error");
|
|
1806
|
+
const errorComponent = {
|
|
1807
|
+
...component,
|
|
1808
|
+
data() {
|
|
1809
|
+
const originalData = component.data ? component.data() : {};
|
|
1810
|
+
return {
|
|
1811
|
+
...originalData,
|
|
1812
|
+
errorCode,
|
|
1813
|
+
errorMessage,
|
|
1814
|
+
showRetry: true,
|
|
1815
|
+
showGoHome: true
|
|
1816
|
+
};
|
|
1817
|
+
}
|
|
1818
|
+
};
|
|
1819
|
+
return errorComponent;
|
|
1820
|
+
} catch (error) {
|
|
1821
|
+
this.error("ErrorHandler", "Error component load failed:", error);
|
|
1822
|
+
throw new Error(`Cannot load error page: ${error.message}`);
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
/**
|
|
1826
|
+
* 폴백 에러 페이지 표시 (모든 에러 페이지가 실패했을 때)
|
|
1827
|
+
*/
|
|
1828
|
+
showFallbackErrorPage(errorCode, errorMessage) {
|
|
1829
|
+
const appElement = document.getElementById("app");
|
|
1830
|
+
if (!appElement) return;
|
|
1831
|
+
const fallbackHTML = `
|
|
1832
|
+
<div class="fallback-error-page" style="
|
|
1833
|
+
display: flex;
|
|
1834
|
+
flex-direction: column;
|
|
1835
|
+
align-items: center;
|
|
1836
|
+
justify-content: center;
|
|
1837
|
+
min-height: 100vh;
|
|
1838
|
+
padding: 2rem;
|
|
1839
|
+
text-align: center;
|
|
1840
|
+
background: #f8f9fa;
|
|
1841
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
1842
|
+
">
|
|
1843
|
+
<div style="
|
|
1844
|
+
background: white;
|
|
1845
|
+
padding: 3rem;
|
|
1846
|
+
border-radius: 12px;
|
|
1847
|
+
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
|
1848
|
+
max-width: 500px;
|
|
1849
|
+
">
|
|
1850
|
+
<h1 style="
|
|
1851
|
+
font-size: 4rem;
|
|
1852
|
+
margin: 0;
|
|
1853
|
+
color: #dc3545;
|
|
1854
|
+
font-weight: 300;
|
|
1855
|
+
">${errorCode}</h1>
|
|
1856
|
+
<h2 style="
|
|
1857
|
+
margin: 1rem 0;
|
|
1858
|
+
color: #495057;
|
|
1859
|
+
font-weight: 400;
|
|
1860
|
+
">${errorMessage}</h2>
|
|
1861
|
+
<p style="
|
|
1862
|
+
color: #6c757d;
|
|
1863
|
+
margin-bottom: 2rem;
|
|
1864
|
+
line-height: 1.5;
|
|
1865
|
+
">\uC694\uCCAD\uD558\uC2E0 \uD398\uC774\uC9C0\uB97C \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.</p>
|
|
1866
|
+
<button onclick="window.location.hash = '#/'" style="
|
|
1867
|
+
background: #007bff;
|
|
1868
|
+
color: white;
|
|
1869
|
+
border: none;
|
|
1870
|
+
padding: 12px 24px;
|
|
1871
|
+
border-radius: 6px;
|
|
1872
|
+
cursor: pointer;
|
|
1873
|
+
font-size: 1rem;
|
|
1874
|
+
transition: background 0.2s;
|
|
1875
|
+
" onmouseover="this.style.background='#0056b3'" onmouseout="this.style.background='#007bff'">
|
|
1876
|
+
\uD648\uC73C\uB85C \uB3CC\uC544\uAC00\uAE30
|
|
1877
|
+
</button>
|
|
1878
|
+
</div>
|
|
1879
|
+
</div>
|
|
1880
|
+
`;
|
|
1881
|
+
appElement.innerHTML = fallbackHTML;
|
|
1882
|
+
this.info("ErrorHandler", `Fallback error page displayed for ${errorCode}`);
|
|
1883
|
+
}
|
|
1884
|
+
/**
|
|
1885
|
+
* 에러 리포팅
|
|
1886
|
+
*/
|
|
1887
|
+
reportError(routeName, error, errorCode) {
|
|
1888
|
+
const errorReport = {
|
|
1889
|
+
route: routeName,
|
|
1890
|
+
errorCode,
|
|
1891
|
+
errorMessage: error.message,
|
|
1892
|
+
stack: error.stack,
|
|
1893
|
+
url: window.location.href,
|
|
1894
|
+
userAgent: navigator.userAgent,
|
|
1895
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1896
|
+
routerConfig: {
|
|
1897
|
+
environment: this.router.config.environment,
|
|
1898
|
+
mode: this.router.config.mode
|
|
1899
|
+
}
|
|
1900
|
+
};
|
|
1901
|
+
this.error("ErrorHandler", "\uB77C\uC6B0\uD130 \uC5D0\uB7EC \uB9AC\uD3EC\uD2B8:", errorReport);
|
|
1902
|
+
}
|
|
1903
|
+
/**
|
|
1904
|
+
* Vue 컴포넌트 생성 (RouteLoader 위임)
|
|
1905
|
+
*/
|
|
1906
|
+
async createVueComponent(routeName) {
|
|
1907
|
+
if (this.router.routeLoader) {
|
|
1908
|
+
return await this.router.routeLoader.createVueComponent(routeName);
|
|
1909
|
+
}
|
|
1910
|
+
throw new Error("RouteLoader not available");
|
|
1911
|
+
}
|
|
1912
|
+
/**
|
|
1913
|
+
* 컴포넌트 렌더링 (ViewManager 위임)
|
|
1914
|
+
*/
|
|
1915
|
+
async renderComponentWithTransition(component, routeName) {
|
|
1916
|
+
if (this.router.renderComponentWithTransition) {
|
|
1917
|
+
return await this.router.renderComponentWithTransition(component, routeName);
|
|
1918
|
+
}
|
|
1919
|
+
throw new Error("Render function not available");
|
|
1920
|
+
}
|
|
1921
|
+
/**
|
|
1922
|
+
* 통합 로깅 시스템
|
|
1923
|
+
* @param {string} level - 로그 레벨 (error, warn, info, debug)
|
|
1924
|
+
* @param {string} component - 컴포넌트 이름 (선택적)
|
|
1925
|
+
* @param {...any} args - 로그 메시지
|
|
1926
|
+
*/
|
|
1927
|
+
log(level, component, ...args) {
|
|
1928
|
+
if (typeof level !== "string" || !this.logLevels.hasOwnProperty(level)) {
|
|
1929
|
+
args = [component, ...args];
|
|
1930
|
+
component = level;
|
|
1931
|
+
level = this.config.debug ? "debug" : "info";
|
|
1932
|
+
}
|
|
1933
|
+
const currentLevelValue = this.logLevels[this.config.logLevel] || this.logLevels.info;
|
|
1934
|
+
const messageLevelValue = this.logLevels[level] || this.logLevels.info;
|
|
1935
|
+
if (messageLevelValue > currentLevelValue) {
|
|
1936
|
+
return;
|
|
1937
|
+
}
|
|
1938
|
+
if (this.config.environment === "production" && messageLevelValue > this.logLevels.warn) {
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1941
|
+
const prefix = component ? `[${component}]` : "[ViewLogic]";
|
|
1942
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().substring(11, 23);
|
|
1943
|
+
switch (level) {
|
|
1944
|
+
case "error":
|
|
1945
|
+
console.error(`${timestamp} ${prefix}`, ...args);
|
|
1946
|
+
break;
|
|
1947
|
+
case "warn":
|
|
1948
|
+
console.warn(`${timestamp} ${prefix}`, ...args);
|
|
1949
|
+
break;
|
|
1950
|
+
case "info":
|
|
1951
|
+
console.info(`${timestamp} ${prefix}`, ...args);
|
|
1952
|
+
break;
|
|
1953
|
+
case "debug":
|
|
1954
|
+
console.log(`${timestamp} ${prefix}`, ...args);
|
|
1955
|
+
break;
|
|
1956
|
+
default:
|
|
1957
|
+
console.log(`${timestamp} ${prefix}`, ...args);
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
/**
|
|
1961
|
+
* 에러 로그 (항상 출력)
|
|
1962
|
+
*/
|
|
1963
|
+
error(component, ...args) {
|
|
1964
|
+
this.log("error", component, ...args);
|
|
1965
|
+
}
|
|
1966
|
+
/**
|
|
1967
|
+
* 경고 로그
|
|
1968
|
+
*/
|
|
1969
|
+
warn(component, ...args) {
|
|
1970
|
+
this.log("warn", component, ...args);
|
|
1971
|
+
}
|
|
1972
|
+
/**
|
|
1973
|
+
* 정보 로그
|
|
1974
|
+
*/
|
|
1975
|
+
info(component, ...args) {
|
|
1976
|
+
this.log("info", component, ...args);
|
|
1977
|
+
}
|
|
1978
|
+
/**
|
|
1979
|
+
* 디버그 로그
|
|
1980
|
+
*/
|
|
1981
|
+
debug(component, ...args) {
|
|
1982
|
+
this.log("debug", component, ...args);
|
|
1983
|
+
}
|
|
1984
|
+
/**
|
|
1985
|
+
* 정리 (메모리 누수 방지)
|
|
1986
|
+
*/
|
|
1987
|
+
destroy() {
|
|
1988
|
+
this.router = null;
|
|
1989
|
+
this.info("ErrorHandler", "ErrorHandler destroyed");
|
|
1990
|
+
}
|
|
1991
|
+
};
|
|
1992
|
+
|
|
1993
|
+
// src/core/ComponentLoader.js
|
|
1994
|
+
var ComponentLoader = class {
|
|
1995
|
+
constructor(router = null, options = {}) {
|
|
1996
|
+
this.config = {
|
|
1997
|
+
basePath: options.basePath || "/src/components",
|
|
1998
|
+
debug: options.debug || false,
|
|
1999
|
+
environment: options.environment || "development",
|
|
2000
|
+
...options
|
|
2001
|
+
};
|
|
2002
|
+
this.router = router;
|
|
2003
|
+
this.loadingPromises = /* @__PURE__ */ new Map();
|
|
2004
|
+
this.unifiedComponents = null;
|
|
2005
|
+
}
|
|
2006
|
+
/**
|
|
2007
|
+
* 로깅 래퍼 메서드
|
|
2008
|
+
*/
|
|
2009
|
+
log(level, ...args) {
|
|
2010
|
+
if (this.router?.errorHandler) {
|
|
2011
|
+
this.router.errorHandler.log(level, "ComponentLoader", ...args);
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
/**
|
|
2015
|
+
* 컴포넌트를 비동기로 로드
|
|
2016
|
+
*/
|
|
2017
|
+
async loadComponent(componentName) {
|
|
2018
|
+
if (!componentName || typeof componentName !== "string") {
|
|
2019
|
+
throw new Error("Component name must be a non-empty string");
|
|
2020
|
+
}
|
|
2021
|
+
if (this.loadingPromises.has(componentName)) {
|
|
2022
|
+
return this.loadingPromises.get(componentName);
|
|
2023
|
+
}
|
|
2024
|
+
const loadPromise = this._loadComponentFromFile(componentName);
|
|
2025
|
+
this.loadingPromises.set(componentName, loadPromise);
|
|
2026
|
+
try {
|
|
2027
|
+
const component = await loadPromise;
|
|
2028
|
+
return component;
|
|
2029
|
+
} catch (error) {
|
|
2030
|
+
throw error;
|
|
2031
|
+
} finally {
|
|
2032
|
+
this.loadingPromises.delete(componentName);
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
/**
|
|
2036
|
+
* 파일에서 컴포넌트 로드
|
|
2037
|
+
*/
|
|
2038
|
+
async _loadComponentFromFile(componentName) {
|
|
2039
|
+
const componentPath = `${this.config.basePath}/${componentName}.js`;
|
|
2040
|
+
try {
|
|
2041
|
+
const module = await import(componentPath);
|
|
2042
|
+
const component = module.default;
|
|
2043
|
+
if (!component) {
|
|
2044
|
+
throw new Error(`Component '${componentName}' has no default export`);
|
|
2045
|
+
}
|
|
2046
|
+
if (!component.name) {
|
|
2047
|
+
component.name = componentName;
|
|
2048
|
+
}
|
|
2049
|
+
this.log("debug", `Component '${componentName}' loaded successfully`);
|
|
2050
|
+
return component;
|
|
2051
|
+
} catch (error) {
|
|
2052
|
+
this.log("error", `Failed to load component '${componentName}':`, error);
|
|
2053
|
+
throw new Error(`Component '${componentName}' not found: ${error.message}`);
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
/**
|
|
2057
|
+
* 컴포넌트 모듈 클리어
|
|
2058
|
+
*/
|
|
2059
|
+
clearComponents() {
|
|
2060
|
+
this.loadingPromises.clear();
|
|
2061
|
+
this.unifiedComponents = null;
|
|
2062
|
+
this.log("debug", "All components cleared");
|
|
2063
|
+
}
|
|
2064
|
+
/**
|
|
2065
|
+
* 환경에 따른 모든 컴포넌트 로딩 (캐싱 지원)
|
|
2066
|
+
*/
|
|
2067
|
+
async loadAllComponents() {
|
|
2068
|
+
if (this.unifiedComponents) {
|
|
2069
|
+
this.log("debug", "Using existing unified components");
|
|
2070
|
+
return this.unifiedComponents;
|
|
2071
|
+
}
|
|
2072
|
+
if (this.config.environment === "production") {
|
|
2073
|
+
return await this._loadProductionComponents();
|
|
2074
|
+
}
|
|
2075
|
+
return await this._loadDevelopmentComponents();
|
|
2076
|
+
}
|
|
2077
|
+
/**
|
|
2078
|
+
* 운영 모드: 통합 컴포넌트 로딩
|
|
2079
|
+
*/
|
|
2080
|
+
async _loadProductionComponents() {
|
|
2081
|
+
try {
|
|
2082
|
+
const componentsPath = `${this.config.routesPath}/_components.js`;
|
|
2083
|
+
this.log("info", "[PRODUCTION] Loading unified components from:", componentsPath);
|
|
2084
|
+
const componentsModule = await import(componentsPath);
|
|
2085
|
+
if (typeof componentsModule.registerComponents === "function") {
|
|
2086
|
+
this.unifiedComponents = componentsModule.components || {};
|
|
2087
|
+
this.log("info", `[PRODUCTION] Unified components loaded: ${Object.keys(this.unifiedComponents).length} components`);
|
|
2088
|
+
return this.unifiedComponents;
|
|
2089
|
+
} else {
|
|
2090
|
+
throw new Error("registerComponents function not found in components module");
|
|
2091
|
+
}
|
|
2092
|
+
} catch (error) {
|
|
2093
|
+
this.log("warn", "[PRODUCTION] Failed to load unified components:", error.message);
|
|
2094
|
+
this.unifiedComponents = {};
|
|
2095
|
+
return {};
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
/**
|
|
2099
|
+
* 개발 모드: 개별 컴포넌트 로딩
|
|
2100
|
+
*/
|
|
2101
|
+
async _loadDevelopmentComponents() {
|
|
2102
|
+
const componentNames = this._getComponentNames();
|
|
2103
|
+
const components = {};
|
|
2104
|
+
this.log("info", `[DEVELOPMENT] Loading individual components: ${componentNames.join(", ")}`);
|
|
2105
|
+
for (const name of componentNames) {
|
|
2106
|
+
try {
|
|
2107
|
+
const component = await this.loadComponent(name);
|
|
2108
|
+
if (component) {
|
|
2109
|
+
components[name] = component;
|
|
2110
|
+
}
|
|
2111
|
+
} catch (loadError) {
|
|
2112
|
+
this.log("warn", `[DEVELOPMENT] Failed to load component ${name}:`, loadError.message);
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
this.unifiedComponents = components;
|
|
2116
|
+
this.log("info", `[DEVELOPMENT] Individual components loaded: ${Object.keys(components).length} components`);
|
|
2117
|
+
return components;
|
|
2118
|
+
}
|
|
2119
|
+
/**
|
|
2120
|
+
* 컴포넌트 이름 목록 가져오기
|
|
2121
|
+
*/
|
|
2122
|
+
_getComponentNames() {
|
|
2123
|
+
if (Array.isArray(this.config.componentNames) && this.config.componentNames.length > 0) {
|
|
2124
|
+
return [...this.config.componentNames];
|
|
2125
|
+
}
|
|
2126
|
+
return [
|
|
2127
|
+
"Button",
|
|
2128
|
+
"Modal",
|
|
2129
|
+
"Card",
|
|
2130
|
+
"Toast",
|
|
2131
|
+
"Input",
|
|
2132
|
+
"Tabs",
|
|
2133
|
+
"Checkbox",
|
|
2134
|
+
"Alert",
|
|
2135
|
+
"DynamicInclude",
|
|
2136
|
+
"HtmlInclude"
|
|
2137
|
+
];
|
|
2138
|
+
}
|
|
2139
|
+
/**
|
|
2140
|
+
* 메모리 정리
|
|
2141
|
+
*/
|
|
2142
|
+
dispose() {
|
|
2143
|
+
this.clearComponents();
|
|
2144
|
+
this.log("debug", "ComponentLoader disposed");
|
|
2145
|
+
this.router = null;
|
|
2146
|
+
}
|
|
2147
|
+
};
|
|
2148
|
+
|
|
2149
|
+
// src/viewlogic-router.js
|
|
2150
|
+
var ViewLogicRouter = class {
|
|
2151
|
+
constructor(options = {}) {
|
|
2152
|
+
this.version = options.version || "1.0.0";
|
|
2153
|
+
this.config = this._buildConfig(options);
|
|
2154
|
+
this.currentHash = "";
|
|
2155
|
+
this.currentVueApp = null;
|
|
2156
|
+
this.previousVueApp = null;
|
|
2157
|
+
this.componentLoader = null;
|
|
2158
|
+
this.transitionInProgress = false;
|
|
2159
|
+
this.isReady = false;
|
|
2160
|
+
this.readyPromise = null;
|
|
2161
|
+
this._boundHandleRouteChange = this.handleRouteChange.bind(this);
|
|
2162
|
+
this.readyPromise = this.initialize();
|
|
2163
|
+
}
|
|
2164
|
+
/**
|
|
2165
|
+
* 설정 빌드 (분리하여 가독성 향상)
|
|
2166
|
+
*/
|
|
2167
|
+
_buildConfig(options) {
|
|
2168
|
+
const defaults = {
|
|
2169
|
+
basePath: "/src",
|
|
2170
|
+
mode: "hash",
|
|
2171
|
+
cacheMode: "memory",
|
|
2172
|
+
cacheTTL: 3e5,
|
|
2173
|
+
maxCacheSize: 50,
|
|
2174
|
+
useLayout: true,
|
|
2175
|
+
defaultLayout: "default",
|
|
2176
|
+
environment: "development",
|
|
2177
|
+
routesPath: "/routes",
|
|
2178
|
+
enableErrorReporting: true,
|
|
2179
|
+
useComponents: true,
|
|
2180
|
+
componentNames: ["Button", "Modal", "Card", "Toast", "Input", "Tabs", "Checkbox", "Alert", "DynamicInclude", "HtmlInclude"],
|
|
2181
|
+
useI18n: true,
|
|
2182
|
+
defaultLanguage: "ko",
|
|
2183
|
+
logLevel: "info",
|
|
2184
|
+
authEnabled: false,
|
|
2185
|
+
loginRoute: "login",
|
|
2186
|
+
protectedRoutes: [],
|
|
2187
|
+
protectedPrefixes: [],
|
|
2188
|
+
publicRoutes: ["login", "register", "home"],
|
|
2189
|
+
checkAuthFunction: null,
|
|
2190
|
+
redirectAfterLogin: "home",
|
|
2191
|
+
authCookieName: "authToken",
|
|
2192
|
+
authFallbackCookieNames: ["accessToken", "token", "jwt"],
|
|
2193
|
+
authStorage: "cookie",
|
|
2194
|
+
authCookieOptions: {},
|
|
2195
|
+
authSkipValidation: false,
|
|
2196
|
+
enableParameterValidation: true,
|
|
2197
|
+
maxParameterLength: 1e3,
|
|
2198
|
+
maxParameterCount: 50,
|
|
2199
|
+
maxArraySize: 100,
|
|
2200
|
+
allowedKeyPattern: /^[a-zA-Z0-9_-]+$/,
|
|
2201
|
+
logSecurityWarnings: true
|
|
2202
|
+
};
|
|
2203
|
+
return { ...defaults, ...options };
|
|
2204
|
+
}
|
|
2205
|
+
/**
|
|
2206
|
+
* 로깅 래퍼 메서드
|
|
2207
|
+
*/
|
|
2208
|
+
log(level, ...args) {
|
|
2209
|
+
if (this.errorHandler) {
|
|
2210
|
+
this.errorHandler.log(level, "Router", ...args);
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
/**
|
|
2214
|
+
* 통합 초기화 - 매니저 생성 → 비동기 로딩 → 라우터 시작
|
|
2215
|
+
*/
|
|
2216
|
+
async initialize() {
|
|
2217
|
+
try {
|
|
2218
|
+
this.cacheManager = new CacheManager(this, this.config);
|
|
2219
|
+
this.routeLoader = new RouteLoader(this, this.config);
|
|
2220
|
+
this.queryManager = new QueryManager(this, this.config);
|
|
2221
|
+
this.errorHandler = new ErrorHandler(this, this.config);
|
|
2222
|
+
if (this.config.useI18n) {
|
|
2223
|
+
this.i18nManager = new I18nManager(this, this.config);
|
|
2224
|
+
if (this.i18nManager.initPromise) {
|
|
2225
|
+
await this.i18nManager.initPromise;
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
if (this.config.authEnabled) {
|
|
2229
|
+
this.authManager = new AuthManager(this, this.config);
|
|
2230
|
+
}
|
|
2231
|
+
if (this.config.useComponents) {
|
|
2232
|
+
this.componentLoader = new ComponentLoader(this, {
|
|
2233
|
+
...this.config,
|
|
2234
|
+
basePath: this.config.basePath + "/components",
|
|
2235
|
+
cache: true,
|
|
2236
|
+
componentNames: this.config.componentNames
|
|
2237
|
+
});
|
|
2238
|
+
await this.componentLoader.loadAllComponents();
|
|
2239
|
+
}
|
|
2240
|
+
this.isReady = true;
|
|
2241
|
+
this.init();
|
|
2242
|
+
} catch (error) {
|
|
2243
|
+
this.log("error", "Router initialization failed:", error);
|
|
2244
|
+
this.isReady = true;
|
|
2245
|
+
this.init();
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
/**
|
|
2249
|
+
* 라우터가 준비될 때까지 대기
|
|
2250
|
+
*/
|
|
2251
|
+
async waitForReady() {
|
|
2252
|
+
if (this.isReady) return true;
|
|
2253
|
+
if (this.readyPromise) {
|
|
2254
|
+
await this.readyPromise;
|
|
2255
|
+
}
|
|
2256
|
+
return this.isReady;
|
|
2257
|
+
}
|
|
2258
|
+
init() {
|
|
2259
|
+
const isHashMode = this.config.mode === "hash";
|
|
2260
|
+
window.addEventListener(
|
|
2261
|
+
isHashMode ? "hashchange" : "popstate",
|
|
2262
|
+
this._boundHandleRouteChange
|
|
2263
|
+
);
|
|
2264
|
+
const initRoute = () => {
|
|
2265
|
+
if (isHashMode && !window.location.hash) {
|
|
2266
|
+
window.location.hash = "#/";
|
|
2267
|
+
} else if (!isHashMode && window.location.pathname === "/") {
|
|
2268
|
+
this.navigateTo("home");
|
|
2269
|
+
} else {
|
|
2270
|
+
this.handleRouteChange();
|
|
2271
|
+
}
|
|
2272
|
+
};
|
|
2273
|
+
if (document.readyState === "loading") {
|
|
2274
|
+
document.addEventListener("DOMContentLoaded", initRoute);
|
|
2275
|
+
} else {
|
|
2276
|
+
requestAnimationFrame(initRoute);
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
handleRouteChange() {
|
|
2280
|
+
const { route, queryParams } = this._parseCurrentLocation();
|
|
2281
|
+
this.queryManager?.setCurrentQueryParams(queryParams);
|
|
2282
|
+
if (route !== this.currentHash || this.queryManager?.hasQueryParamsChanged(queryParams)) {
|
|
2283
|
+
this.currentHash = route;
|
|
2284
|
+
this.loadRoute(route);
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
/**
|
|
2288
|
+
* 현재 위치 파싱 (분리하여 가독성 향상)
|
|
2289
|
+
*/
|
|
2290
|
+
_parseCurrentLocation() {
|
|
2291
|
+
if (this.config.mode === "hash") {
|
|
2292
|
+
const hashPath = window.location.hash.slice(1) || "/";
|
|
2293
|
+
const [pathPart, queryPart] = hashPath.split("?");
|
|
2294
|
+
let route = "home";
|
|
2295
|
+
if (pathPart && pathPart !== "/") {
|
|
2296
|
+
route = pathPart.startsWith("/") ? pathPart.slice(1) : pathPart;
|
|
2297
|
+
}
|
|
2298
|
+
return {
|
|
2299
|
+
route: route || "home",
|
|
2300
|
+
queryParams: this.queryManager?.parseQueryString(queryPart || window.location.search.slice(1)) || {}
|
|
2301
|
+
};
|
|
2302
|
+
} else {
|
|
2303
|
+
return {
|
|
2304
|
+
route: window.location.pathname.slice(1) || "home",
|
|
2305
|
+
queryParams: this.queryManager?.parseQueryString(window.location.search.slice(1)) || {}
|
|
2306
|
+
};
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
async loadRoute(routeName) {
|
|
2310
|
+
const inProgress = this.transitionInProgress;
|
|
2311
|
+
if (inProgress) {
|
|
2312
|
+
return;
|
|
2313
|
+
}
|
|
2314
|
+
try {
|
|
2315
|
+
this.transitionInProgress = true;
|
|
2316
|
+
const authResult = this.authManager ? await this.authManager.checkAuthentication(routeName) : { allowed: true, reason: "auth_disabled" };
|
|
2317
|
+
if (!authResult.allowed) {
|
|
2318
|
+
if (this.authManager) {
|
|
2319
|
+
this.authManager.emitAuthEvent("auth_required", {
|
|
2320
|
+
originalRoute: routeName,
|
|
2321
|
+
loginRoute: this.config.loginRoute
|
|
2322
|
+
});
|
|
2323
|
+
const redirectUrl = routeName !== this.config.loginRoute ? `${this.config.loginRoute}?redirect=${encodeURIComponent(routeName)}` : this.config.loginRoute;
|
|
2324
|
+
this.navigateTo(redirectUrl);
|
|
2325
|
+
}
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2328
|
+
const appElement = document.getElementById("app");
|
|
2329
|
+
if (!appElement) {
|
|
2330
|
+
throw new Error("App element not found");
|
|
2331
|
+
}
|
|
2332
|
+
const component = await this.routeLoader.createVueComponent(routeName);
|
|
2333
|
+
await this.renderComponentWithTransition(component, routeName);
|
|
2334
|
+
} catch (error) {
|
|
2335
|
+
this.log("error", `Route loading failed [${routeName}]:`, error.message);
|
|
2336
|
+
if (this.errorHandler) {
|
|
2337
|
+
await this.errorHandler.handleRouteError(routeName, error);
|
|
2338
|
+
} else {
|
|
2339
|
+
console.error("[Router] No error handler available");
|
|
2340
|
+
}
|
|
2341
|
+
} finally {
|
|
2342
|
+
this.transitionInProgress = false;
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
async renderComponentWithTransition(vueComponent, routeName) {
|
|
2346
|
+
const appElement = document.getElementById("app");
|
|
2347
|
+
if (!appElement) return;
|
|
2348
|
+
const newPageContainer = document.createElement("div");
|
|
2349
|
+
newPageContainer.className = "page-container page-entered";
|
|
2350
|
+
newPageContainer.id = `page-${routeName}-${Date.now()}`;
|
|
2351
|
+
const existingContainers = appElement.querySelectorAll(".page-container");
|
|
2352
|
+
existingContainers.forEach((container) => {
|
|
2353
|
+
container.classList.remove("page-entered");
|
|
2354
|
+
container.classList.add("page-exiting");
|
|
2355
|
+
});
|
|
2356
|
+
appElement.appendChild(newPageContainer);
|
|
2357
|
+
if (this.config.environment === "development" && vueComponent._style) {
|
|
2358
|
+
this.applyStyle(vueComponent._style, routeName);
|
|
2359
|
+
}
|
|
2360
|
+
const { createApp } = Vue;
|
|
2361
|
+
const newVueApp = createApp(vueComponent);
|
|
2362
|
+
newVueApp.config.globalProperties.$router = {
|
|
2363
|
+
navigateTo: (route, params) => this.navigateTo(route, params),
|
|
2364
|
+
getCurrentRoute: () => this.getCurrentRoute(),
|
|
2365
|
+
getQueryParams: () => this.queryManager?.getQueryParams() || {},
|
|
2366
|
+
getQueryParam: (key) => this.queryManager?.getQueryParam(key),
|
|
2367
|
+
setQueryParams: (params, replace) => this.queryManager?.setQueryParams(params, replace),
|
|
2368
|
+
removeQueryParams: (keys) => this.queryManager?.removeQueryParams(keys),
|
|
2369
|
+
currentRoute: this.currentHash,
|
|
2370
|
+
currentQuery: this.queryManager?.getQueryParams() || {}
|
|
2371
|
+
};
|
|
2372
|
+
newVueApp.mount(`#${newPageContainer.id}`);
|
|
2373
|
+
requestAnimationFrame(() => {
|
|
2374
|
+
this.cleanupPreviousPages();
|
|
2375
|
+
this.transitionInProgress = false;
|
|
2376
|
+
});
|
|
2377
|
+
if (this.currentVueApp) {
|
|
2378
|
+
this.previousVueApp = this.currentVueApp;
|
|
2379
|
+
}
|
|
2380
|
+
this.currentVueApp = newVueApp;
|
|
2381
|
+
}
|
|
2382
|
+
cleanupPreviousPages() {
|
|
2383
|
+
const appElement = document.getElementById("app");
|
|
2384
|
+
if (!appElement) return;
|
|
2385
|
+
const fragment = document.createDocumentFragment();
|
|
2386
|
+
const exitingContainers = appElement.querySelectorAll(".page-container.page-exiting");
|
|
2387
|
+
exitingContainers.forEach((container) => container.remove());
|
|
2388
|
+
if (this.previousVueApp) {
|
|
2389
|
+
try {
|
|
2390
|
+
this.previousVueApp.unmount();
|
|
2391
|
+
} catch (error) {
|
|
2392
|
+
}
|
|
2393
|
+
this.previousVueApp = null;
|
|
2394
|
+
}
|
|
2395
|
+
appElement.querySelector(".loading")?.remove();
|
|
2396
|
+
}
|
|
2397
|
+
applyStyle(css, routeName) {
|
|
2398
|
+
const existing = document.querySelector(`style[data-route="${routeName}"]`);
|
|
2399
|
+
if (existing) existing.remove();
|
|
2400
|
+
if (css) {
|
|
2401
|
+
const style = document.createElement("style");
|
|
2402
|
+
style.textContent = css;
|
|
2403
|
+
style.setAttribute("data-route", routeName);
|
|
2404
|
+
document.head.appendChild(style);
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
navigateTo(routeName, params = null) {
|
|
2408
|
+
if (typeof routeName === "object") {
|
|
2409
|
+
params = routeName.params || null;
|
|
2410
|
+
routeName = routeName.route;
|
|
2411
|
+
}
|
|
2412
|
+
if (routeName !== this.currentHash && this.queryManager) {
|
|
2413
|
+
this.queryManager.clearQueryParams();
|
|
2414
|
+
}
|
|
2415
|
+
this.updateURL(routeName, params);
|
|
2416
|
+
}
|
|
2417
|
+
getCurrentRoute() {
|
|
2418
|
+
return this.currentHash;
|
|
2419
|
+
}
|
|
2420
|
+
updateURL(route, params = null) {
|
|
2421
|
+
const queryParams = params || this.queryManager?.getQueryParams() || {};
|
|
2422
|
+
const queryString = this.queryManager?.buildQueryString(queryParams) || "";
|
|
2423
|
+
const buildURL = (route2, queryString2, isHash = true) => {
|
|
2424
|
+
const base = route2 === "home" ? "/" : `/${route2}`;
|
|
2425
|
+
const url = queryString2 ? `${base}?${queryString2}` : base;
|
|
2426
|
+
return isHash ? `#${url}` : url;
|
|
2427
|
+
};
|
|
2428
|
+
if (this.config.mode === "hash") {
|
|
2429
|
+
const newHash = buildURL(route, queryString);
|
|
2430
|
+
if (window.location.hash !== newHash) {
|
|
2431
|
+
window.location.hash = newHash;
|
|
2432
|
+
}
|
|
2433
|
+
} else {
|
|
2434
|
+
const newPath = buildURL(route, queryString, false);
|
|
2435
|
+
const isSameRoute = window.location.pathname === (route === "home" ? "/" : `/${route}`);
|
|
2436
|
+
if (isSameRoute) {
|
|
2437
|
+
window.history.replaceState({}, "", newPath);
|
|
2438
|
+
} else {
|
|
2439
|
+
window.history.pushState({}, "", newPath);
|
|
2440
|
+
}
|
|
2441
|
+
this.handleRouteChange();
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
/**
|
|
2445
|
+
* 라우터 정리 (메모리 누수 방지)
|
|
2446
|
+
*/
|
|
2447
|
+
destroy() {
|
|
2448
|
+
window.removeEventListener(
|
|
2449
|
+
this.config.mode === "hash" ? "hashchange" : "popstate",
|
|
2450
|
+
this._boundHandleRouteChange
|
|
2451
|
+
);
|
|
2452
|
+
if (this.currentVueApp) {
|
|
2453
|
+
this.currentVueApp.unmount();
|
|
2454
|
+
this.currentVueApp = null;
|
|
2455
|
+
}
|
|
2456
|
+
if (this.previousVueApp) {
|
|
2457
|
+
this.previousVueApp.unmount();
|
|
2458
|
+
this.previousVueApp = null;
|
|
2459
|
+
}
|
|
2460
|
+
Object.values(this).forEach((manager) => {
|
|
2461
|
+
if (manager && typeof manager.destroy === "function") {
|
|
2462
|
+
manager.destroy();
|
|
2463
|
+
}
|
|
2464
|
+
});
|
|
2465
|
+
this.cacheManager?.clearAll();
|
|
2466
|
+
const appElement = document.getElementById("app");
|
|
2467
|
+
if (appElement) {
|
|
2468
|
+
appElement.innerHTML = "";
|
|
2469
|
+
}
|
|
2470
|
+
this.log("info", "Router destroyed");
|
|
2471
|
+
}
|
|
2472
|
+
};
|
|
2473
|
+
export {
|
|
2474
|
+
ViewLogicRouter
|
|
2475
|
+
};
|
|
2476
|
+
//# sourceMappingURL=viewlogic-router.js.map
|