라우트 패턴은 끝 슬래시 없는 형태(*/admin 등)로만 정의되는데(routes.json 규약), Router.match 가 들어온 경로의 끝 슬래시를 정규화하지 않아 /admin/ (끝 슬래시)이 어떤 라우트에도 매칭되지 않고 404 로 떨어졌다. 특히 미인증 상태로 /admin/ 진입 시 대시보드 → 로그인 리다이렉트 흐름 대신 404 가 노출됐다. match 진입에 normalizePathname 을 두어 루트(/)를 제외한 경로의 끝 슬래시(연속 포함)를 제거한 뒤 대조한다. match 는 라우팅 단일 진입점이라 모든 호출부가 함께 정상화되고, 다운스트림(auth back-URL, getAuthType)은 원본 pathname 을 그대로 써 의미 변화가 없다. 기존 릴리스부터 있던 라우터 견고성 결함이며 자산 URL 이중 모드 변경과는 무관하다(matchPattern 은 미변경, routes.json 은 81건 정상 로드 확인).
391 lines
11 KiB
TypeScript
391 lines
11 KiB
TypeScript
import { AuthManager } from '../auth/AuthManager';
|
|
import { createLogger } from '../utils/Logger';
|
|
import { suffixed } from '../support/assetUrl';
|
|
|
|
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<string, string>;
|
|
/** 쿼리 파라미터 (배열 쿼리 파라미터 지원: key[]=[...]) */
|
|
query?: Record<string, string | string[]>;
|
|
auth_required?: boolean;
|
|
auth_type?: AuthType;
|
|
meta?: RouteMeta;
|
|
}
|
|
|
|
/**
|
|
* 라우트 매칭 결과
|
|
*/
|
|
export interface RouteMatch {
|
|
route: Route;
|
|
params: Record<string, string>;
|
|
}
|
|
|
|
/**
|
|
* 이벤트 핸들러 타입
|
|
*/
|
|
type EventHandler = (...args: any[]) => void;
|
|
|
|
/**
|
|
* Router 클래스
|
|
*
|
|
* URL 패턴 매칭과 동적 파라미터 추출을 담당합니다.
|
|
*/
|
|
export class Router {
|
|
private routes: Route[] = [];
|
|
private templateIdentifier: string;
|
|
private eventHandlers: Map<string, EventHandler[]> = 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<void> {
|
|
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<void> {
|
|
try {
|
|
const version = cacheVersion !== undefined && cacheVersion > 0 ? cacheVersion : null;
|
|
const response = await fetch(
|
|
suffixed(`/api/templates/${this.templateIdentifier}/routes`, 'json', version),
|
|
);
|
|
|
|
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 {
|
|
const normalizedPath = this.normalizePathname(pathname);
|
|
|
|
for (const route of this.routes) {
|
|
const params = this.matchPattern(route.path, normalizedPath);
|
|
|
|
if (params !== null) {
|
|
return {
|
|
route,
|
|
params,
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* URL 경로의 끝 슬래시를 정규화합니다.
|
|
*
|
|
* 브라우저가 `/admin/` 처럼 끝 슬래시가 붙은 경로로 진입해도 끝 슬래시 없는
|
|
* 라우트 패턴(언어 prefix 형태 포함)에 매칭되도록, 루트(`/`)를 제외한 나머지
|
|
* 경로의 끝 슬래시를 제거한다. 라우트 패턴은 끝 슬래시 없는 형태로만 정의되므로
|
|
* (routes.json 규약), 미정규화 시 `/admin/` 이 어떤 라우트에도 매칭되지 않아
|
|
* 404 로 떨어지고 — 미인증 진입 시 로그인 리다이렉트 대신 404 가 노출된다.
|
|
*
|
|
* @param pathname 원본 URL 경로
|
|
* @returns 끝 슬래시가 정규화된 경로 (루트는 `/` 유지)
|
|
* @since engine-v1.54.1
|
|
*/
|
|
private normalizePathname(pathname: string): string {
|
|
if (pathname.length > 1 && pathname.endsWith('/')) {
|
|
return pathname.replace(/\/+$/, '') || '/';
|
|
}
|
|
|
|
return pathname;
|
|
}
|
|
|
|
/**
|
|
* 패턴과 경로를 매칭하고 파라미터를 추출합니다.
|
|
*
|
|
* 지원되는 패턴:
|
|
* - /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<string, string> | 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<string, string> = {};
|
|
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<void> {
|
|
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<string, string | string[]> = {};
|
|
|
|
// 모든 키를 순회하며 배열 값 처리
|
|
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<void> {
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|