import { AuthManager } from '../auth/AuthManager'; import { createLogger } from '../utils/Logger'; const logger = createLogger('Router'); /** * 라우트 메타 정보 */ export interface RouteMeta { title?: string; [key: string]: unknown; } /** * 인증 타입 */ export type AuthType = 'admin' | 'user'; /** * 라우트 인터페이스 */ export interface Route { path: string; layout?: string; redirect?: string; endpoint?: string; params?: Record; /** 쿼리 파라미터 (배열 쿼리 파라미터 지원: key[]=[...]) */ query?: Record; auth_required?: boolean; auth_type?: AuthType; meta?: RouteMeta; } /** * 라우트 매칭 결과 */ export interface RouteMatch { route: Route; params: Record; } /** * 이벤트 핸들러 타입 */ type EventHandler = (...args: any[]) => void; /** * Router 클래스 * * URL 패턴 매칭과 동적 파라미터 추출을 담당합니다. */ export class Router { private routes: Route[] = []; private templateIdentifier: string; private eventHandlers: Map = new Map(); private pendingNavigation: string | null = null; private isNavigating: boolean = false; constructor(templateIdentifier: string) { this.templateIdentifier = templateIdentifier; this.initPopstateListener(); } /** * 브라우저 뒤로가기/앞으로가기 이벤트 리스너 초기화 */ private initPopstateListener(): void { window.addEventListener('popstate', this.handlePopState); } /** * popstate 이벤트 핸들러 (화살표 함수로 this 바인딩 유지) */ private handlePopState = (): void => { this.navigateToCurrentPath(); }; /** * 이벤트 핸들러 등록 */ on(event: string, handler: EventHandler): void { if (!this.eventHandlers.has(event)) { this.eventHandlers.set(event, []); } this.eventHandlers.get(event)!.push(handler); } /** * 이벤트 발생 (async 핸들러 지원) */ private async emit(event: string, ...args: any[]): Promise { const handlers = this.eventHandlers.get(event); if (handlers) { // 모든 핸들러가 완료될 때까지 대기 (async 핸들러 지원) await Promise.all(handlers.map(handler => handler(...args))); } } /** * API에서 라우트 목록을 로드합니다. * * @param cacheVersion - 확장 캐시 버전. 전달 시 `?v=${version}` 쿼리로 부착되어 * 백엔드 응답 캐시(PublicTemplateController::getRoutes)의 버전 키를 일치시킵니다. * 미전달 시 `v=0`으로 해석되어 구 버전과 TTL 공유 — 확장 라이프사이클 직후 * 재로드 시에는 반드시 최신 cache_version을 전달해야 합니다. */ async loadRoutes(cacheVersion?: number): Promise { try { const versionQuery = cacheVersion !== undefined && cacheVersion > 0 ? `?v=${cacheVersion}` : ''; const response = await fetch(`/api/templates/${this.templateIdentifier}/routes.json${versionQuery}`); if (!response.ok) { throw new Error(`Failed to load routes: ${response.statusText}`); } const result = await response.json(); if (!result.success) { throw new Error('Failed to load routes from API'); } // API 응답 형태: { success: true, data: { version: "1.0.0", routes: [...] } } if (result.data && Array.isArray(result.data.routes)) { this.routes = result.data.routes; logger.log(`Loaded ${this.routes.length} routes${cacheVersion ? ` (v=${cacheVersion})` : ''}`); } else { throw new Error('Invalid routes data format'); } } catch (error) { logger.error('Error loading routes:', error); throw error; } } /** * 라우트 목록을 직접 설정합니다 (병렬 로딩용). * * @param routes - 설정할 라우트 배열 */ setRoutes(routes: Route[]): void { this.routes = routes; logger.log(`Set ${this.routes.length} routes`); } /** * 현재 경로와 매칭되는 라우트를 찾습니다. * * @param pathname - 매칭할 URL 경로 * @returns 매칭된 라우트와 파라미터, 없으면 null */ match(pathname: string): RouteMatch | null { for (const route of this.routes) { const params = this.matchPattern(route.path, pathname); if (params !== null) { return { route, params, }; } } return null; } /** * 패턴과 경로를 매칭하고 파라미터를 추출합니다. * * 지원되는 패턴: * - /admin - 정확한 매칭 * - /admin/users/:id - 동적 파라미터 (:id) * - * /admin - 언어 prefix 지원 (/admin, /ko/admin, /en/admin) * * @param pattern - 라우트 패턴 * @param pathname - 실제 URL 경로 * @returns 추출된 파라미터 객체, 매칭 실패 시 null */ private matchPattern(pattern: string, pathname: string): Record | null { // 파라미터 이름 추출 const paramNames: string[] = []; // 패턴을 정규식으로 변환 let regexPattern = pattern // :id 형태의 동적 파라미터를 캡처 그룹으로 변환 .replace(/:([^/]+)/g, (_, paramName) => { paramNames.push(paramName); return '([^/]+)'; }); // 패턴 시작의 */를 optional 언어 prefix로 변환 (예: /ko, /en) // */admin → /admin 또는 /ko/admin 또는 /en/admin if (regexPattern.startsWith('*/')) { regexPattern = '(?:/[^/]+)?' + regexPattern.slice(1); } // 특수 문자 이스케이프 regexPattern = regexPattern.replace(/\//g, '\\/'); // 정규식 생성 (전체 문자열 매칭) const regex = new RegExp(`^${regexPattern}$`); const match = pathname.match(regex); if (!match) { return null; } // 파라미터 추출 const params: Record = {}; paramNames.forEach((name, index) => { params[name] = match[index + 1]; }); return params; } /** * 현재 로드된 라우트 목록을 반환합니다. */ getRoutes(): Route[] { return [...this.routes]; } /** * 경로 기반 인증 타입 판단 * * @param route - 라우트 정보 * @param pathname - URL 경로 * @returns 인증 타입 */ private getAuthType(route: Route, pathname: string): AuthType { // 1. 라우트에 명시된 경우 사용 if (route.auth_type) { return route.auth_type; } // 2. 경로 기반 자동 판단 if (pathname.startsWith('/admin')) { return 'admin'; } return 'user'; } /** * 현재 URL 경로를 처리하여 매칭되는 라우트를 찾고 이벤트를 발생시킵니다. */ async navigateToCurrentPath(): Promise { const pathname = window.location.pathname; const search = window.location.search; const matchResult = this.match(pathname); if (!matchResult) { logger.warn(`No route matched for path: ${pathname}`); this.emit('routeNotFound', pathname); return; } // 리다이렉트 라우트인 경우 처리 if (matchResult.route.redirect) { logger.log(`Redirecting from ${pathname} to ${matchResult.route.redirect}`); this.navigate(matchResult.route.redirect); return; } // 인증 검증 if (matchResult.route.auth_required) { const authType = this.getAuthType(matchResult.route, pathname); const authManager = AuthManager.getInstance(); // 이미 인증 상태가 확인된 경우 API 호출 스킵 let isAuthenticated = authManager.isAuthenticated() && authManager.getAuthType() === authType; // 인증 상태가 없거나 타입이 다른 경우에만 API 호출 if (!isAuthenticated) { isAuthenticated = await authManager.checkAuth(authType); } if (!isAuthenticated) { const loginUrl = authManager.getLoginRedirectUrl(authType, pathname + search); logger.log(`Not authenticated, redirecting to: ${loginUrl}`); window.location.href = loginUrl; return; } } // query 파라미터 파싱 (배열 쿼리 파라미터 지원) const queryParams = new URLSearchParams(search); const query: Record = {}; // 모든 키를 순회하며 배열 값 처리 for (const key of queryParams.keys()) { // 이미 처리된 키는 스킵 if (key in query) continue; const values = queryParams.getAll(key); if (values.length > 1) { // 여러 값이 있으면 배열로 저장 query[key] = values; } else if (values.length === 1) { // key[]로 끝나면 단일 값도 배열로 저장 (일관성 유지) if (key.endsWith('[]')) { query[key] = values; } else { query[key] = values[0]; } } } // 라우트 변경 이벤트 발생 (핸들러가 완료될 때까지 대기) await this.emit('routeChange', { path: pathname, layout: matchResult.route.layout, endpoint: matchResult.route.endpoint, params: { ...(matchResult.route.params || {}), ...matchResult.params }, query, auth_required: matchResult.route.auth_required, auth_type: matchResult.route.auth_type, meta: matchResult.route.meta, }); } /** * 지정된 경로로 이동합니다. * * 동시에 여러 navigate 호출이 발생할 경우, 마지막 요청만 처리합니다. * 이전 navigate가 진행 중이면 대기했다가 최신 경로로 이동합니다. * * @param path - 이동할 경로 (예: '/admin/dashboard') */ navigate(path: string): void { // 이미 navigate가 진행 중이면 대기열에 추가 if (this.isNavigating) { this.pendingNavigation = path; return; } this.executeNavigation(path); } /** * 실제 navigate 실행 */ private async executeNavigation(path: string): Promise { this.isNavigating = true; this.pendingNavigation = null; try { // URL 변경 (브라우저 히스토리에 추가) window.history.pushState({}, '', path); // 라우트 매칭 및 렌더링 (완료까지 대기) await this.navigateToCurrentPath(); } finally { this.isNavigating = false; // 대기 중인 navigate가 있으면 실행 if (this.pendingNavigation) { const nextPath = this.pendingNavigation; this.executeNavigation(nextPath); } } } }