nginx 의 정규식 location 은 프리픽스 location 보다 먼저 매칭되므로, `location ~* \.(js|css|json)$` 블록이 있는 서버에서는 확장자 붙은 동적 엔드포인트가 `try_files ... /index.php` 폴백이 실행될 기회 없이 404 가 된다. aaPanel/CyberPanel/Plesk 기본 템플릿에 들어있어 드물지 않으며, 관리자 화면조차 뜨지 않아 "서버 설정을 고치세요" 안내가 순환 참조가 된다. 관례를 깬 쪽이 G7 이므로 해소 책임도 G7 에 두고, 두 형태를 모두 서빙한 뒤 환경에 따라 택일한다. 확장자 형태는 영구 유지한다 — 제거하면 URL 을 하드코딩한 서드파티 확장이 깨진다. - 라우트: dualSuffix / dualSuffixSegment / dualAsset 매크로로 23개 엔드포인트와 프로브를 이중 등록. 확장자 형태를 먼저 등록한다 — 확장자 없는 쪽이 더 느슨한 패턴이라 순서가 뒤집히면 `.json` 요청까지 삼킨다 - URL 생성: 서버 App\Support\AssetUrl, 프론트 core/support/assetUrl.ts 로 집약. 기본 모드에서 생성 결과는 치환 이전과 문자열까지 동일하다 (쿼리 순서 포함 — 순서가 바뀌면 의미는 같아도 HTTP 캐시 키가 갈린다) - 자가 복구: 부트스트랩 자산 로드 실패 시 확장자 없는 형태로 1회 단방향 전환. 역방향 금지 + 기존 재시도 예산 공유로 무한 왕복을 막는다 - 감지·전환: 인스톨러 프로브(설치 시 확정) / 관리자 환경설정 일반 탭 / g7:asset-url-mode. 판정은 상태코드가 아니라 매직 토큰 + Content-Type 으로 한다. 상태코드만 보면 "404 대신 200 + 에러 HTML" 환경에서 영원히 오판한다 - 봇은 JavaScript 를 실행하지 않아 자가 복구가 닿지 않으므로, 모드 변경 시 SEO 프리렌더 캐시를 비워 재생성시킨다 가드: audit 룰 2종(dynamic-route-static-extension / asset-url-builder-required), Playwright 6건(정적 블록 가로채기 시뮬레이션), 루프 방지 불변식 L1~L9 전수 red 증명. 부수 정리: 양 Composer 에 중복돼 있던 확장 에셋 수집 123줄을 트레이트로 통합하고, 자산 확장자 화이트리스트 누락(.map)과 경로 정규화 우회 가능성을 함께 교정. sir.kr 커뮤니티의 hang 님께서 제보해주셨습니다.
1104 lines
33 KiB
TypeScript
1104 lines
33 KiB
TypeScript
/**
|
|
* NOTE: @since versions (engine-v1.x.x) refer to internal template engine
|
|
* development iterations, not G7 platform release versions.
|
|
*/
|
|
/**
|
|
* LayoutLoader.ts
|
|
*
|
|
* 레이아웃 데이터를 비동기로 로드하고 TemplateEngine을 통해 렌더링하는 클래스
|
|
*
|
|
* 역할:
|
|
* - API로부터 레이아웃 JSON 데이터 로드
|
|
* - 로드된 데이터를 DOM에 렌더링
|
|
* - 에러 발생 시 에러 UI 표시
|
|
*/
|
|
|
|
import type { ComponentRegistry } from './ComponentRegistry';
|
|
import type { ErrorHandlingMap } from '../types/ErrorHandling';
|
|
import { createLogger } from '../utils/Logger';
|
|
import { fetchWithRetry } from './networkResilience';
|
|
import { suffixed } from '../support/assetUrl';
|
|
import { G7DevToolsCore } from '../devtools/G7DevToolsCore';
|
|
import { getApiClient } from '../api/ApiClient';
|
|
|
|
const logger = createLogger('LayoutLoader');
|
|
|
|
/**
|
|
* 레이아웃 컴포넌트 데이터 인터페이스
|
|
*/
|
|
export interface LayoutComponent {
|
|
/** 컴포넌트 타입 */
|
|
type: string;
|
|
|
|
/** 컴포넌트 props */
|
|
props?: Record<string, any>;
|
|
|
|
/** 자식 컴포넌트 목록 */
|
|
children?: LayoutComponent[];
|
|
|
|
/** 텍스트 콘텐츠 (children이 없을 경우) */
|
|
text?: string;
|
|
}
|
|
|
|
/**
|
|
* 초기화 액션 정의 (레이아웃 로드 시 실행)
|
|
*/
|
|
export interface InitActionDefinition {
|
|
/** 액션 핸들러 이름 */
|
|
handler: string;
|
|
/** 액션 타겟 (선택적) */
|
|
target?: string;
|
|
/** 액션 파라미터 (선택적) */
|
|
params?: Record<string, any>;
|
|
/** 조건부 실행 (표현식이 truthy일 때만 실행) */
|
|
if?: string;
|
|
/** 핸들러 결과를 상태에 저장할 위치 (선택적) */
|
|
resultTo?: {
|
|
/** 저장 대상: "_local" (로컬 상태), "_global" (전역 상태), "_isolated" (격리된 상태) */
|
|
target: '_local' | '_global' | '_isolated';
|
|
/** 상태 키 */
|
|
key: string;
|
|
/** 병합 모드: "replace" | "shallow" | "deep" (기본값: "deep") */
|
|
merge?: 'replace' | 'shallow' | 'deep';
|
|
};
|
|
/** 액션 성공 시 실행할 후속 액션 (단일 또는 배열) */
|
|
onSuccess?: InitActionDefinition | InitActionDefinition[];
|
|
/** 액션 실패 시 실행할 후속 액션 (단일 또는 배열) */
|
|
onError?: InitActionDefinition | InitActionDefinition[];
|
|
/** conditions 핸들러용 조건 분기 배열 (선택적) */
|
|
conditions?: Array<{
|
|
if: string;
|
|
then?: any;
|
|
else?: any;
|
|
}>;
|
|
}
|
|
|
|
/**
|
|
* 외부 스크립트 정의 (레이아웃 로드 시 동적 로드)
|
|
*
|
|
* 레이아웃 진입 시 외부 스크립트를 1회만 로드합니다.
|
|
* 이미 로드된 스크립트는 중복 로드하지 않습니다.
|
|
*
|
|
* @since engine-v1.8.0
|
|
*
|
|
* @example
|
|
* ```json
|
|
* {
|
|
* "scripts": [
|
|
* {
|
|
* "src": "//t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js",
|
|
* "id": "daum_postcode_script",
|
|
* "if": "{{_global.installedPlugins?.find(p => p.identifier === 'sirsoft-daum_postcode' && p.status === 'active')}}"
|
|
* }
|
|
* ]
|
|
* }
|
|
* ```
|
|
*/
|
|
export interface LayoutScript {
|
|
/** 스크립트 URL (필수) */
|
|
src: string;
|
|
/** 스크립트 요소 ID (중복 로드 방지용, 필수) */
|
|
id: string;
|
|
/** 조건부 로드 표현식 (선택적, 예: "{{_global.pluginActive}}") */
|
|
if?: string;
|
|
/**
|
|
* 조건부 로드 조건 (복합 조건)
|
|
*
|
|
* if 속성의 상위 호환으로 AND/OR 그룹을 지원합니다.
|
|
* if와 conditions가 둘 다 있으면 if가 우선 평가됩니다.
|
|
*
|
|
* @example
|
|
* // OR 그룹: 분석 플래그가 활성화되거나 admin일 때 로드
|
|
* {
|
|
* "src": "//analytics.js",
|
|
* "id": "analytics_script",
|
|
* "conditions": {
|
|
* "or": ["{{_global.featureFlags?.enableAnalytics}}", "{{_global.user?.role === 'admin'}}"]
|
|
* }
|
|
* }
|
|
*
|
|
* @since engine-v1.10.0
|
|
*/
|
|
conditions?: import('./helpers/ConditionEvaluator').ConditionsProperty;
|
|
/** 비동기 로드 여부 (기본: true) */
|
|
async?: boolean;
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 경고 타입
|
|
*
|
|
* 호환성 경고, 시스템 경고 등 다양한 경고 유형을 지원합니다.
|
|
*/
|
|
export type LayoutWarningType = 'compatibility' | 'deprecation' | 'license' | 'security' | 'system';
|
|
|
|
/**
|
|
* 레이아웃 경고 레벨
|
|
*/
|
|
export type LayoutWarningLevel = 'info' | 'warning' | 'error';
|
|
|
|
/**
|
|
* 레이아웃 경고 인터페이스
|
|
*
|
|
* 백엔드에서 프론트엔드로 전달되는 경고 정보입니다.
|
|
*/
|
|
export interface LayoutWarning {
|
|
/** 경고 고유 ID (dismiss 처리에 사용) */
|
|
id: string;
|
|
/** 경고 유형 */
|
|
type: LayoutWarningType;
|
|
/** 경고 레벨 */
|
|
level: LayoutWarningLevel;
|
|
/** 사용자에게 표시할 메시지 */
|
|
message: string;
|
|
/** 추가 메타데이터 (경고 유형에 따라 다름) */
|
|
[key: string]: any;
|
|
}
|
|
|
|
/**
|
|
* 전역 헤더 규칙 인터페이스
|
|
*
|
|
* 레이아웃에서 정의한 API 호출에 자동으로 적용되는 HTTP 헤더를 정의합니다.
|
|
* pattern에 매칭되는 엔드포인트에만 헤더가 적용됩니다.
|
|
*
|
|
* @since engine-v1.16.0
|
|
*
|
|
* @example
|
|
* ```json
|
|
* {
|
|
* "globalHeaders": [
|
|
* { "pattern": "*", "headers": { "X-Template": "basic" } },
|
|
* { "pattern": "/api/modules/sirsoft-ecommerce/*", "headers": { "X-Cart-Key": "{{_global.cartKey}}" } }
|
|
* ]
|
|
* }
|
|
* ```
|
|
*/
|
|
export interface GlobalHeaderRule {
|
|
/** 엔드포인트 매칭 패턴 ("*", "/api/shop/*" 등) */
|
|
pattern: string;
|
|
/** 적용할 HTTP 헤더 키-값 쌍 (표현식 지원) */
|
|
headers: Record<string, string>;
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 데이터 인터페이스
|
|
*/
|
|
export interface LayoutData {
|
|
/** 레이아웃 버전 */
|
|
version: string;
|
|
|
|
/** 레이아웃 이름 */
|
|
layout_name: string;
|
|
|
|
/** 최상위 컴포넌트 목록 */
|
|
components: LayoutComponent[];
|
|
|
|
/** 레이아웃 메타데이터 */
|
|
meta?: {
|
|
title?: string;
|
|
description?: string;
|
|
auth_required?: boolean;
|
|
[key: string]: any;
|
|
};
|
|
|
|
/** 데이터 소스 */
|
|
data_sources?: any[];
|
|
|
|
/**
|
|
* 초기화 액션 목록 (레이아웃 로드 시 실행)
|
|
*
|
|
* @deprecated init_actions 대신 initActions 사용을 권장합니다
|
|
*/
|
|
init_actions?: InitActionDefinition[];
|
|
|
|
/**
|
|
* 초기화 액션 목록 (레이아웃 로드 시 실행)
|
|
*
|
|
* state 블록 적용 후, 렌더링 전에 실행되어 _local/_global 상태를 설정합니다.
|
|
* 동적 표현식({{...}})을 사용한 초기값 설정이 가능합니다.
|
|
*
|
|
* @since engine-v1.11.0
|
|
*/
|
|
initActions?: InitActionDefinition[];
|
|
|
|
/**
|
|
* 외부 스크립트 목록 (레이아웃 로드 시 동적 로드)
|
|
*
|
|
* 레이아웃 진입 시 외부 스크립트를 1회만 로드합니다.
|
|
* if 조건이 있으면 조건을 만족할 때만 로드합니다.
|
|
*
|
|
* @since engine-v1.8.0
|
|
*
|
|
* @example
|
|
* ```json
|
|
* {
|
|
* "scripts": [
|
|
* {
|
|
* "src": "//cdn.example.com/library.js",
|
|
* "id": "my_library",
|
|
* "if": "{{_global.pluginActive}}"
|
|
* }
|
|
* ]
|
|
* }
|
|
* ```
|
|
*/
|
|
scripts?: LayoutScript[];
|
|
|
|
/**
|
|
* 시스템 경고 목록
|
|
*
|
|
* 백엔드에서 감지된 경고(버전 호환성 등)를 프론트엔드에 전달합니다.
|
|
* 템플릿의 베이스 레이아웃에서 이 필드를 기반으로 Alert 컴포넌트를 렌더링합니다.
|
|
*
|
|
* @example
|
|
* ```json
|
|
* {
|
|
* "warnings": [
|
|
* {
|
|
* "id": "compatibility_123",
|
|
* "type": "compatibility",
|
|
* "level": "warning",
|
|
* "message": "오버라이드가 모듈 버전과 호환되지 않습니다"
|
|
* }
|
|
* ]
|
|
* }
|
|
* ```
|
|
*/
|
|
warnings?: LayoutWarning[];
|
|
|
|
/**
|
|
* 레이아웃 전환 시 오버레이 설정
|
|
*
|
|
* 페이지 전환 시 순수 DOM 조작으로 오버레이를 표시하여
|
|
* React 비동기 렌더링 중 stale DOM flash를 방지합니다.
|
|
*
|
|
* - true: 기본 opaque 스타일 (document.body 전체)
|
|
* - { enabled, style, target }: 상세 설정
|
|
* - false/미설정: 오버레이 없음 (기존 동작)
|
|
*
|
|
* @since engine-v1.23.0
|
|
*/
|
|
transition_overlay?: boolean | {
|
|
enabled: boolean;
|
|
style?: 'opaque' | 'blur' | 'fade' | 'skeleton' | 'spinner';
|
|
/** 오버레이를 삽입할 컨테이너 요소 ID (미지정 시 document.body) */
|
|
target?: string;
|
|
/**
|
|
* target DOM 미존재 시 대체 타겟 요소 ID
|
|
*
|
|
* 3단계 fallback chain: target → fallback_target → #app (전체 페이지)
|
|
* 예: 마이페이지 탭 전환 시 target="mypage_tab_content",
|
|
* 페이지 전환 시 fallback_target="main_content_area"
|
|
*
|
|
* @since engine-v1.24.2
|
|
*/
|
|
fallback_target?: string;
|
|
/**
|
|
* 스켈레톤 UI 렌더링 설정
|
|
*
|
|
* style이 'skeleton'일 때 사용됩니다.
|
|
* 지정된 컴포넌트에 레이아웃 컴포넌트 트리를 전달하여
|
|
* 동적으로 스켈레톤 UI를 생성합니다.
|
|
*
|
|
* @since engine-v1.24.0
|
|
*/
|
|
skeleton?: {
|
|
/** 스켈레톤 렌더러 컴포넌트 이름 (컴포넌트 레지스트리에 등록된 이름) */
|
|
component: string;
|
|
/** 스켈레톤 애니메이션 타입 */
|
|
animation?: 'pulse' | 'wave' | 'none';
|
|
/** iteration 블록의 기본 반복 횟수 */
|
|
iteration_count?: number;
|
|
};
|
|
/**
|
|
* 스피너 로딩 UI 설정
|
|
*
|
|
* style이 'spinner'일 때 사용됩니다.
|
|
* 커스텀 로딩 컴포넌트를 지정하거나, 미지정 시 기본 CSS 스피너로 폴백합니다.
|
|
*
|
|
* @since engine-v1.29.0
|
|
*/
|
|
spinner?: {
|
|
/** 로딩 컴포넌트 이름 (컴포넌트 레지스트리에 등록된 이름) */
|
|
component?: string;
|
|
/** 커스텀 로딩 텍스트 (미지정 시 nav.loading 번역 키 사용) */
|
|
text?: string;
|
|
};
|
|
/**
|
|
* spinner 가 표시되어야 할 데이터소스 ID 목록
|
|
*
|
|
* blocking 데이터소스가 없는 페이지에서도 명시된 progressive 데이터소스가
|
|
* fetch 완료될 때까지 transition_overlay 가 표시됩니다.
|
|
*
|
|
* 호환되는 loading_strategy: blocking, progressive
|
|
* 자동 무시되는 케이스: background, websocket, 존재하지 않는 ID
|
|
*
|
|
* 백엔드 검증(UpdateLayoutContentRequest)에서 background/websocket ID 는 사전 차단됩니다.
|
|
*
|
|
* @since engine-v1.30.0
|
|
*/
|
|
wait_for?: string[];
|
|
};
|
|
|
|
/**
|
|
* 레이아웃 레벨 에러 핸들링 설정
|
|
*
|
|
* 이 레이아웃에서 발생하는 모든 에러에 대한 기본 핸들링을 정의합니다.
|
|
* 액션/데이터소스 레벨 설정보다 우선순위가 낮습니다.
|
|
*
|
|
* @example
|
|
* ```json
|
|
* {
|
|
* "errorHandling": {
|
|
* "403": { "handler": "showErrorPage", "params": { "target": "content" } },
|
|
* "404": { "handler": "toast", "params": { "type": "warning", "message": "$t:errors.not_found" } },
|
|
* "default": { "handler": "toast", "params": { "type": "error", "message": "{{error.message}}" } }
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
errorHandling?: ErrorHandlingMap;
|
|
|
|
/**
|
|
* 레이아웃 접근 권한 목록
|
|
*
|
|
* 이 레이아웃에 접근하기 위해 필요한 권한 목록입니다.
|
|
* - 빈 배열([]): 공개 레이아웃 (인증된 사용자 모두 접근 가능)
|
|
* - 권한 배열: AND 조건으로 모든 권한 필요
|
|
* - undefined: 부모 레이아웃 권한 상속
|
|
*
|
|
* 서버에서 권한 검증 후 401(미인증) 또는 403(권한 없음) 응답을 반환합니다.
|
|
*
|
|
* @since engine-v1.14.0
|
|
*
|
|
* @example
|
|
* ```json
|
|
* {
|
|
* "permissions": ["core.users.read"]
|
|
* }
|
|
* ```
|
|
*/
|
|
permissions?: string[];
|
|
|
|
/**
|
|
* 전역 헤더 규칙 (globalHeaders)
|
|
*
|
|
* 레이아웃에서 정의한 모든 API 호출(data_sources, apiCall)에
|
|
* 자동으로 적용되는 HTTP 헤더를 정의합니다.
|
|
* pattern에 매칭되는 엔드포인트에만 헤더가 적용됩니다.
|
|
*
|
|
* 적용 우선순위:
|
|
* 1. globalHeaders (낮음)
|
|
* 2. data_source.headers / apiCall params.headers (높음)
|
|
*
|
|
* 상속 시 병합 규칙:
|
|
* - 부모와 자식의 globalHeaders를 병합
|
|
* - 동일 pattern은 headers 병합 (자식 우선)
|
|
*
|
|
* @since engine-v1.16.0
|
|
*
|
|
* @example
|
|
* ```json
|
|
* {
|
|
* "globalHeaders": [
|
|
* { "pattern": "*", "headers": { "X-Template": "basic" } },
|
|
* { "pattern": "/api/modules/sirsoft-ecommerce/*", "headers": { "X-Cart-Key": "{{_global.cartKey}}" } }
|
|
* ]
|
|
* }
|
|
* ```
|
|
*/
|
|
globalHeaders?: GlobalHeaderRule[];
|
|
|
|
/**
|
|
* 명명된 액션 정의 (named_actions)
|
|
*
|
|
* 재사용 가능한 액션을 정의합니다.
|
|
* 컴포넌트의 actionRef 속성으로 참조할 수 있습니다.
|
|
* 부모-자식 레이아웃 간 병합 시 자식이 부모를 오버라이드합니다.
|
|
*
|
|
* @since engine-v1.19.0
|
|
*
|
|
* @example
|
|
* ```json
|
|
* {
|
|
* "named_actions": {
|
|
* "searchProducts": {
|
|
* "handler": "navigate",
|
|
* "params": {
|
|
* "path": "/admin/ecommerce/products",
|
|
* "mergeQuery": true,
|
|
* "query": { "page": 1, "search_field": "..." }
|
|
* }
|
|
* }
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
named_actions?: Record<string, any>;
|
|
|
|
/**
|
|
* 정적 상수 정의 (defines)
|
|
*
|
|
* 렌더링 중 변하지 않는 상수 값을 정의합니다.
|
|
* 매핑 테이블, 설정 값 등 정적 데이터에 적합합니다.
|
|
* 컴포넌트에서 `{{_defines.xxx}}`로 접근합니다.
|
|
*
|
|
* @since engine-v1.9.0
|
|
*
|
|
* @example
|
|
* ```json
|
|
* {
|
|
* "defines": {
|
|
* "currencyFlagMap": {
|
|
* "KRW": "kr",
|
|
* "USD": "us",
|
|
* "JPY": "jp",
|
|
* "EUR": "eu"
|
|
* },
|
|
* "MAX_ITEMS": 100,
|
|
* "STATUS_COLORS": {
|
|
* "active": "green",
|
|
* "inactive": "gray"
|
|
* }
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
defines?: Record<string, any>;
|
|
|
|
/**
|
|
* 로컬 상태 초기값 정의 (state)
|
|
*
|
|
* @deprecated state 대신 initLocal 사용을 권장합니다
|
|
*/
|
|
state?: Record<string, any>;
|
|
|
|
/**
|
|
* _local 상태 초기값 정의 (initLocal)
|
|
*
|
|
* 레이아웃 로드 시 _local 상태의 초기값을 정의합니다.
|
|
* 정적 값만 지원하며, 동적 표현식({{...}})은 사용할 수 없습니다.
|
|
* 폼의 기본값, 탭 초기 상태 등 정적 초기값 설정에 적합합니다.
|
|
*
|
|
* 적용 순서:
|
|
* 1. 레이아웃 initLocal (정적 기본값)
|
|
* 2. 데이터소스 initLocal (API 응답으로 병합)
|
|
* 3. initActions (동적 초기화)
|
|
*
|
|
* 병합 방식:
|
|
* - 데이터소스 initLocal이 있으면 깊은 병합 (deep merge)
|
|
* - 레이아웃 initLocal의 값은 기본값으로 유지
|
|
* - 데이터소스 응답에서 추가되는 값만 병합
|
|
*
|
|
* @since engine-v1.11.0
|
|
*
|
|
* @example
|
|
* ```json
|
|
* {
|
|
* "initLocal": {
|
|
* "activeTab": "basic",
|
|
* "hasChanges": false,
|
|
* "formData": {
|
|
* "name": "",
|
|
* "type": "basic",
|
|
* "use_comment": false
|
|
* }
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
initLocal?: Record<string, any>;
|
|
|
|
/**
|
|
* _global 상태 초기값 정의 (initGlobal)
|
|
*
|
|
* 레이아웃 로드 시 _global 상태의 초기값을 정의합니다.
|
|
* 정적 값만 지원하며, 동적 표현식({{...}})은 사용할 수 없습니다.
|
|
*
|
|
* 적용 순서:
|
|
* 1. 레이아웃 initGlobal (정적 기본값)
|
|
* 2. 데이터소스 initGlobal (API 응답으로 병합)
|
|
* 3. initActions (동적 초기화)
|
|
*
|
|
* 병합 방식:
|
|
* - 데이터소스 initGlobal이 있으면 깊은 병합 (deep merge)
|
|
* - 레이아웃 initGlobal의 값은 기본값으로 유지
|
|
* - 데이터소스 응답에서 추가되는 값만 병합
|
|
*
|
|
* @since engine-v1.11.0
|
|
*
|
|
* @example
|
|
* ```json
|
|
* {
|
|
* "initGlobal": {
|
|
* "sidebarOpen": true,
|
|
* "theme": "light"
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
initGlobal?: Record<string, any>;
|
|
|
|
/**
|
|
* _isolated 상태 초기값 정의 (initIsolated)
|
|
*
|
|
* 레이아웃 로드 시 _isolated 상태의 초기값을 정의합니다.
|
|
* isolatedState 속성이 정의된 컴포넌트 내에서만 사용됩니다.
|
|
* 정적 값만 지원하며, 동적 표현식({{...}})은 사용할 수 없습니다.
|
|
*
|
|
* 적용 순서:
|
|
* 1. 레이아웃 initIsolated (정적 기본값)
|
|
* 2. 데이터소스 initIsolated (API 응답으로 병합)
|
|
*
|
|
* 병합 방식:
|
|
* - 데이터소스 initIsolated가 있으면 깊은 병합 (deep merge)
|
|
* - 레이아웃 initIsolated의 값은 기본값으로 유지
|
|
* - 데이터소스 응답에서 추가되는 값만 병합
|
|
*
|
|
* @since engine-v1.11.0
|
|
*
|
|
* @example
|
|
* ```json
|
|
* {
|
|
* "initIsolated": {
|
|
* "selectedItems": [],
|
|
* "filterOptions": {
|
|
* "status": "all"
|
|
* }
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
initIsolated?: Record<string, any>;
|
|
|
|
/**
|
|
* 파생 상태 정의 (computed)
|
|
*
|
|
* 다른 상태나 데이터에서 파생되는 값을 정의합니다.
|
|
* 의존하는 데이터가 변경되면 자동으로 재계산됩니다.
|
|
* 컴포넌트에서 `{{_computed.xxx}}`로 접근합니다.
|
|
*
|
|
* 표현식에서 사용 가능한 컨텍스트:
|
|
* - `_defines`: defines에 정의된 상수
|
|
* - `_global`: 전역 상태
|
|
* - `_local`: 로컬 상태
|
|
* - 데이터 소스 결과
|
|
*
|
|
* @since engine-v1.9.0
|
|
*
|
|
* @example
|
|
* ```json
|
|
* {
|
|
* "computed": {
|
|
* "flagCode": "{{_defines.currencyFlagMap[currency.code] ?? 'xx'}}",
|
|
* "totalPrice": "{{items.reduce((sum, i) => sum + i.price, 0)}}",
|
|
* "isAdmin": "{{_global.user?.role === 'admin'}}"
|
|
* }
|
|
* }
|
|
* ```
|
|
*
|
|
* $switch 표현식도 지원합니다:
|
|
*
|
|
* @example
|
|
* ```json
|
|
* {
|
|
* "computed": {
|
|
* "statusBadgeClass": {
|
|
* "$switch": "{{row.status_variant}}",
|
|
* "$cases": {
|
|
* "success": "bg-green-100 text-green-800",
|
|
* "danger": "bg-red-100 text-red-800"
|
|
* },
|
|
* "$default": "bg-gray-100 text-gray-600"
|
|
* }
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
computed?: Record<string, string | ComputedSwitchDefinition>;
|
|
}
|
|
|
|
/**
|
|
* $switch 형태의 computed 정의
|
|
*/
|
|
export interface ComputedSwitchDefinition {
|
|
$switch: string;
|
|
$cases: Record<string, any>;
|
|
$default?: any;
|
|
}
|
|
|
|
/**
|
|
* LayoutLoader 에러 클래스
|
|
*/
|
|
export class LayoutLoaderError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public code: string,
|
|
public details?: any
|
|
) {
|
|
super(message);
|
|
this.name = 'LayoutLoaderError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* LayoutLoader 클래스
|
|
*
|
|
* 레이아웃 JSON을 로드하고 DOM에 렌더링
|
|
*/
|
|
export class LayoutLoader {
|
|
/** ComponentRegistry 인스턴스 */
|
|
private componentRegistry: ComponentRegistry;
|
|
|
|
/** 현재 로드된 레이아웃 데이터 */
|
|
private currentLayout: LayoutData | null = null;
|
|
|
|
/** 레이아웃 캐시 (templateId:layoutPath를 키로 사용) */
|
|
private layoutCache = new Map<string, Promise<LayoutData>>();
|
|
|
|
/** 확장 기능 캐시 버전 (모듈/플러그인 활성화 시 갱신됨) */
|
|
private cacheVersion: number = 0;
|
|
|
|
/**
|
|
* 생성자
|
|
*
|
|
* @param componentRegistry ComponentRegistry 인스턴스
|
|
*/
|
|
constructor(componentRegistry: ComponentRegistry) {
|
|
this.componentRegistry = componentRegistry;
|
|
}
|
|
|
|
/**
|
|
* 캐시 버전 설정
|
|
*
|
|
* 모듈/플러그인 활성화 시 캐시 버전이 변경되어
|
|
* 새로운 레이아웃 데이터를 서버에서 가져오도록 합니다.
|
|
*
|
|
* @param version 캐시 버전 (타임스탬프)
|
|
*/
|
|
public setCacheVersion(version: number): void {
|
|
if (this.cacheVersion !== version) {
|
|
logger.log('Cache version updated:', this.cacheVersion, '->', version);
|
|
this.cacheVersion = version;
|
|
// 캐시 버전 변경 시 기존 캐시 클리어 (새 버전으로 로드 필요)
|
|
this.layoutCache.clear();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 현재 캐시 버전 반환
|
|
*/
|
|
public getCacheVersion(): number {
|
|
return this.cacheVersion;
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 데이터 로드 (캐싱 지원)
|
|
*
|
|
* @param templateId 템플릿 식별자
|
|
* @param layoutPath 레이아웃 경로
|
|
* @returns 레이아웃 데이터
|
|
*/
|
|
public async loadLayout(templateId: string, layoutPath: string): Promise<LayoutData> {
|
|
// 캐시 키 생성
|
|
const cacheKey = `${templateId}:${layoutPath}`;
|
|
|
|
// 캐시 확인
|
|
if (this.layoutCache.has(cacheKey)) {
|
|
logger.log('Loading layout from cache:', layoutPath);
|
|
const cachedData = await this.layoutCache.get(cacheKey)!;
|
|
// IMPORTANT: deep clone하여 반환 (원본 캐시 데이터 보존)
|
|
// data_sources.params 등이 변형되어도 캐시가 오염되지 않도록 함
|
|
const data = JSON.parse(JSON.stringify(cachedData)) as LayoutData;
|
|
this.currentLayout = data;
|
|
|
|
// DevTools에 레이아웃 로드 추적 (캐시 히트)
|
|
G7DevToolsCore.getInstance().trackLayoutLoad(layoutPath, templateId, data, 'cache');
|
|
|
|
return data;
|
|
}
|
|
|
|
// 캐시 미스 - 새로운 로딩 Promise 생성 및 캐시에 저장
|
|
// 실패한 promise 를 캐시에 남기면 이후 모든 재요청이 그 rejection 을 그대로 재사용해
|
|
// 영구 실패가 된다(네트워크가 복구돼도 다시 시도할 기회가 없다). 실패 시 캐시에서 제거.
|
|
// @since engine-v1.53.0
|
|
const loadPromise = this.fetchLayout(templateId, layoutPath);
|
|
this.layoutCache.set(cacheKey, loadPromise);
|
|
|
|
loadPromise.catch(() => {
|
|
if (this.layoutCache.get(cacheKey) === loadPromise) {
|
|
this.layoutCache.delete(cacheKey);
|
|
}
|
|
});
|
|
|
|
return loadPromise;
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 데이터 실제 로드 (내부 메서드)
|
|
*
|
|
* @param templateId 템플릿 식별자
|
|
* @param layoutPath 레이아웃 경로
|
|
* @param skipToken 토큰 없이 요청 (401 재시도 시 사용)
|
|
* @returns 레이아웃 데이터
|
|
*/
|
|
private async fetchLayout(templateId: string, layoutPath: string, skipToken: boolean = false): Promise<LayoutData> {
|
|
try {
|
|
// API 엔드포인트 구성 (캐시 버전 쿼리 파라미터 추가)
|
|
// 시스템 레이아웃 분기: __preview__ 는 별도 API 엔드포인트 사용
|
|
const version = this.cacheVersion > 0 ? this.cacheVersion : null;
|
|
let apiUrl: string;
|
|
if (layoutPath.startsWith('__preview__/')) {
|
|
const token = layoutPath.replace('__preview__/', '');
|
|
apiUrl = suffixed(`/api/layouts/preview/${token}`, 'json', version);
|
|
} else {
|
|
apiUrl = suffixed(`/api/layouts/${templateId}/${layoutPath}`, 'json', version);
|
|
}
|
|
|
|
logger.log('Fetching layout from API:', apiUrl);
|
|
|
|
// 인증 토큰 가져오기 (있으면 포함, 없으면 비회원 요청)
|
|
const apiClient = getApiClient();
|
|
const token = skipToken ? null : apiClient.getToken();
|
|
const headers: Record<string, string> = {
|
|
'Accept': 'application/json',
|
|
};
|
|
if (token) {
|
|
headers['Authorization'] = `Bearer ${token}`;
|
|
}
|
|
|
|
// API 호출 (인증 헤더 포함)
|
|
// 네트워크 일시 실패(응답 없음)에만 재시도한다. 401 등 HTTP 응답은 그대로 넘어와
|
|
// 아래 상태코드 분기가 종전대로 동작한다 (두 재시도는 계층이 달라 중첩되지 않는다).
|
|
// @since engine-v1.53.0
|
|
const response = await fetchWithRetry(apiUrl, { init: { headers }, label: `layout: ${layoutPath}` });
|
|
|
|
// JSON 파싱 (에러 응답도 JSON일 수 있으므로 먼저 파싱)
|
|
let responseData: any;
|
|
try {
|
|
responseData = await response.json();
|
|
} catch {
|
|
responseData = null;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
// 401 Unauthorized 처리: 토큰이 무효하면 삭제 후 토큰 없이 재시도
|
|
// 로그인 페이지 등 공개 레이아웃 접근을 보장하기 위함
|
|
if (response.status === 401 && !skipToken && token) {
|
|
logger.log('Token invalid, removing and retrying without token');
|
|
apiClient.removeToken();
|
|
// hadToken 마킹: 재시도 또한 401 로 끝나면 가드(TemplateApp.showRouteError)가
|
|
// 토큰 보유 상태였음을 알 수 있어야 reason='session_expired' 를 부여할 수 있다.
|
|
// 가드 진입 시점에는 이미 apiClient.removeToken() 으로 토큰이 제거되어
|
|
// getToken() 만 보면 익명 진입과 구분이 불가능하므로 details 에 마킹.
|
|
try {
|
|
return await this.fetchLayout(templateId, layoutPath, true);
|
|
} catch (retryError) {
|
|
if (
|
|
retryError instanceof LayoutLoaderError &&
|
|
retryError.details?.status === 401
|
|
) {
|
|
throw new LayoutLoaderError(retryError.message, retryError.code, {
|
|
...retryError.details,
|
|
hadToken: true,
|
|
});
|
|
}
|
|
throw retryError;
|
|
}
|
|
}
|
|
|
|
// API 응답에서 에러 메시지 추출
|
|
const apiMessage = responseData?.message || responseData?.error;
|
|
|
|
throw new LayoutLoaderError(
|
|
`Failed to fetch layout: ${response.status} ${response.statusText}`,
|
|
'FETCH_FAILED',
|
|
{
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
url: apiUrl,
|
|
apiMessage,
|
|
}
|
|
);
|
|
}
|
|
|
|
// API 응답에서 실제 레이아웃 데이터 추출
|
|
const data = responseData.data || responseData;
|
|
|
|
// 레이아웃 데이터 검증
|
|
this.validateLayoutData(data);
|
|
|
|
// 현재 레이아웃 저장
|
|
this.currentLayout = data;
|
|
|
|
// DevTools에 레이아웃 로드 추적 (API 로드)
|
|
G7DevToolsCore.getInstance().trackLayoutLoad(layoutPath, templateId, data, 'api');
|
|
|
|
logger.log('Layout fetched and cached successfully:', data.layout_name);
|
|
|
|
return data;
|
|
} catch (error) {
|
|
// 에러 발생 시 캐시에서 제거
|
|
const cacheKey = `${templateId}:${layoutPath}`;
|
|
this.layoutCache.delete(cacheKey);
|
|
|
|
if (error instanceof LayoutLoaderError) {
|
|
throw error;
|
|
}
|
|
|
|
throw new LayoutLoaderError(
|
|
'Failed to load layout',
|
|
'LOAD_FAILED',
|
|
{ originalError: error }
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 프리페치 (백그라운드 로딩)
|
|
*
|
|
* @param templateId 템플릿 식별자
|
|
* @param layoutPath 레이아웃 경로
|
|
* @returns 레이아웃 데이터 Promise
|
|
*/
|
|
public prefetchLayout(templateId: string, layoutPath: string): Promise<LayoutData> {
|
|
return this.loadLayout(templateId, layoutPath);
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 데이터 검증
|
|
*
|
|
* @param data 레이아웃 데이터
|
|
*/
|
|
private validateLayoutData(data: any): void {
|
|
// 필수 필드 검증
|
|
if (!data.version) {
|
|
throw new LayoutLoaderError(
|
|
'Layout data missing required field: version',
|
|
'VALIDATION_FAILED',
|
|
{ field: 'version' }
|
|
);
|
|
}
|
|
|
|
if (!data.layout_name) {
|
|
throw new LayoutLoaderError(
|
|
'Layout data missing required field: layout_name',
|
|
'VALIDATION_FAILED',
|
|
{ field: 'layout_name' }
|
|
);
|
|
}
|
|
|
|
if (!Array.isArray(data.components)) {
|
|
throw new LayoutLoaderError(
|
|
'Layout data field "components" must be an array',
|
|
'VALIDATION_FAILED',
|
|
{ field: 'components', value: data.components }
|
|
);
|
|
}
|
|
|
|
logger.log('Layout data validation passed');
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 렌더링
|
|
*
|
|
* @param container 렌더링할 컨테이너 요소
|
|
* @param layout 레이아웃 데이터 (생략 시 현재 로드된 레이아웃 사용)
|
|
*/
|
|
public renderLayout(container: HTMLElement, layout?: LayoutData): void {
|
|
try {
|
|
const layoutData = layout || this.currentLayout;
|
|
|
|
if (!layoutData) {
|
|
throw new LayoutLoaderError(
|
|
'No layout data to render',
|
|
'NO_LAYOUT_DATA'
|
|
);
|
|
}
|
|
|
|
logger.log('Rendering layout:', layoutData.layout_name);
|
|
|
|
// 컨테이너 초기화
|
|
container.innerHTML = '';
|
|
|
|
// 컴포넌트 렌더링
|
|
this.renderComponents(container, layoutData.components);
|
|
|
|
logger.log('Layout rendered successfully');
|
|
} catch (error) {
|
|
logger.error('Render error:', error);
|
|
|
|
// 에러 UI 표시
|
|
this.renderErrorState(container, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 컴포넌트 목록 렌더링
|
|
*
|
|
* @param container 컨테이너 요소
|
|
* @param components 컴포넌트 목록
|
|
*/
|
|
private renderComponents(container: HTMLElement, components: LayoutComponent[]): void {
|
|
for (const componentData of components) {
|
|
const element = this.renderComponent(componentData);
|
|
if (element) {
|
|
container.appendChild(element);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 개별 컴포넌트 렌더링
|
|
*
|
|
* @param componentData 컴포넌트 데이터
|
|
* @returns 렌더링된 DOM 요소 또는 null
|
|
*/
|
|
private renderComponent(componentData: LayoutComponent): HTMLElement | null {
|
|
try {
|
|
// 컴포넌트 조회
|
|
const Component = this.componentRegistry.getComponent(componentData.type);
|
|
|
|
if (!Component) {
|
|
logger.warn(`Component not found: ${componentData.type}`);
|
|
return this.createPlaceholderElement(componentData.type);
|
|
}
|
|
|
|
// 컴포넌트 렌더링을 위한 div 생성
|
|
const wrapper = document.createElement('div');
|
|
wrapper.setAttribute('data-component', componentData.type);
|
|
|
|
// props 설정
|
|
if (componentData.props) {
|
|
Object.entries(componentData.props).forEach(([key, value]) => {
|
|
wrapper.setAttribute(`data-prop-${key}`, JSON.stringify(value));
|
|
});
|
|
}
|
|
|
|
// 자식 요소 렌더링
|
|
if (componentData.children && componentData.children.length > 0) {
|
|
this.renderComponents(wrapper, componentData.children);
|
|
} else if (componentData.text) {
|
|
wrapper.textContent = componentData.text;
|
|
}
|
|
|
|
return wrapper;
|
|
} catch (error) {
|
|
logger.error(`Error rendering component ${componentData.type}:`, error);
|
|
return this.createErrorElement(componentData.type, error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 플레이스홀더 요소 생성
|
|
*
|
|
* @param componentType 컴포넌트 타입
|
|
* @returns 플레이스홀더 DOM 요소
|
|
*/
|
|
private createPlaceholderElement(componentType: string): HTMLElement {
|
|
const div = document.createElement('div');
|
|
div.className = 'component-placeholder';
|
|
div.setAttribute('data-component-type', componentType);
|
|
div.innerHTML = `
|
|
<div style="padding: 1rem; border: 2px dashed #ccc; background: #f9f9f9; text-align: center;">
|
|
<p style="margin: 0; color: #666;">Component not found: <strong>${componentType}</strong></p>
|
|
</div>
|
|
`;
|
|
return div;
|
|
}
|
|
|
|
/**
|
|
* 컴포넌트 에러 요소 생성
|
|
*
|
|
* @param componentType 컴포넌트 타입
|
|
* @param error 에러 객체
|
|
* @returns 에러 DOM 요소
|
|
*/
|
|
private createErrorElement(componentType: string, error: any): HTMLElement {
|
|
const div = document.createElement('div');
|
|
div.className = 'component-error';
|
|
div.setAttribute('data-component-type', componentType);
|
|
div.innerHTML = `
|
|
<div style="padding: 1rem; border: 2px solid #f44336; background: #ffebee; border-radius: 4px;">
|
|
<p style="margin: 0 0 0.5rem 0; color: #c62828; font-weight: bold;">Component Error: ${componentType}</p>
|
|
<p style="margin: 0; color: #666; font-size: 0.875rem;">${error?.message || 'Unknown error'}</p>
|
|
</div>
|
|
`;
|
|
return div;
|
|
}
|
|
|
|
/**
|
|
* 에러 상태 렌더링
|
|
*
|
|
* @param container 컨테이너 요소
|
|
* @param error 에러 객체
|
|
*/
|
|
private renderErrorState(container: HTMLElement, error: any): void {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
const errorCode = error instanceof LayoutLoaderError ? error.code : 'UNKNOWN_ERROR';
|
|
|
|
container.innerHTML = `
|
|
<div class="layout-error" data-error-code="${errorCode}" style="
|
|
padding: 2rem;
|
|
text-align: center;
|
|
background: #ffebee;
|
|
border: 2px solid #f44336;
|
|
border-radius: 8px;
|
|
margin: 2rem auto;
|
|
max-width: 600px;
|
|
">
|
|
<h2 style="
|
|
margin: 0 0 1rem 0;
|
|
color: #c62828;
|
|
font-size: 1.5rem;
|
|
">레이아웃 로드 실패</h2>
|
|
|
|
<p style="
|
|
margin: 0 0 1rem 0;
|
|
color: #666;
|
|
font-size: 1rem;
|
|
">${errorMessage}</p>
|
|
|
|
<p style="
|
|
margin: 0;
|
|
color: #999;
|
|
font-size: 0.875rem;
|
|
">오류 코드: ${errorCode}</p>
|
|
|
|
<button
|
|
onclick="window.location.reload()"
|
|
style="
|
|
margin-top: 1.5rem;
|
|
padding: 0.75rem 1.5rem;
|
|
background: #f44336;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 1rem;
|
|
"
|
|
>페이지 새로고침</button>
|
|
</div>
|
|
`;
|
|
|
|
logger.error('Error state rendered:', errorCode, errorMessage);
|
|
}
|
|
|
|
/**
|
|
* 현재 레이아웃 데이터 조회
|
|
*/
|
|
public getCurrentLayout(): LayoutData | null {
|
|
return this.currentLayout;
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 데이터 및 캐시 클리어
|
|
*/
|
|
public clear(): void {
|
|
this.currentLayout = null;
|
|
this.layoutCache.clear();
|
|
logger.log('Layout data and cache cleared');
|
|
}
|
|
}
|
|
|
|
export default LayoutLoader;
|