Files
HeuJung 9bcd97098b fix(core): 끝 슬래시가 붙은 경로(/admin/)가 라우트에 매칭되지 않던 문제
라우트 패턴은 끝 슬래시 없는 형태(*/admin 등)로만 정의되는데(routes.json
규약), Router.match 가 들어온 경로의 끝 슬래시를 정규화하지 않아 /admin/
(끝 슬래시)이 어떤 라우트에도 매칭되지 않고 404 로 떨어졌다. 특히 미인증
상태로 /admin/ 진입 시 대시보드 → 로그인 리다이렉트 흐름 대신 404 가 노출됐다.

match 진입에 normalizePathname 을 두어 루트(/)를 제외한 경로의 끝
슬래시(연속 포함)를 제거한 뒤 대조한다. match 는 라우팅 단일 진입점이라
모든 호출부가 함께 정상화되고, 다운스트림(auth back-URL, getAuthType)은
원본 pathname 을 그대로 써 의미 변화가 없다.

기존 릴리스부터 있던 라우터 견고성 결함이며 자산 URL 이중 모드 변경과는
무관하다(matchPattern 은 미변경, routes.json 은 81건 정상 로드 확인).
2026-07-23 21:34:44 +09:00

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);
}
}
}
}