모바일에서 새로고침을 연타하면 진행 중이던 요청이 취소되는데, 화면 구성에 필요한 요청 하나만 그렇게 끊겨도 전면 "초기화 실패" 로 끝났다. 재시도가 전혀 없었고, 로컬은 왕복이 수 ms 라 재현되지 않아 오래 남아 있었다. fetch 는 4xx/5xx 로 reject 하지 않고 Response.ok=false 로 resolve 한다. 따라서 TypeError 는 "응답 자체가 없었다"(취소·커넥션 유실)는 뜻이며, 그 경우만 재시도해야 한다. HTTP 응답은 그대로 호출부에 넘겨 기존 상태코드 분기와 이중으로 겹치지 않게 했다. - routes.json / components.json / 레이아웃 JSON: 네트워크 실패에만 지수 백오프 재시도 - 실패한 promise 가 레이아웃 캐시에 남아 영구 실패가 되던 문제 수정 - 문서 이탈(pagehide) 중에는 에러 화면을 그리지 않는다 (버려질 문서) - 확장 에셋 로더의 실패 계약을 resolve → reject 로 (실패를 발생 지점에서 표면화) - 번들 로드가 실패로 확정되면 오지 않을 핸들러를 5초간 기다리지 않는다 - 미등록 핸들러의 raw 내부 식별자가 토스트로 노출되던 것을 차단 - 번들이 끝내 부재해도 백지 대신 새로고침 버튼이 있는 안내 화면 정상 경로 성능은 종전과 같다. script 태그는 정적으로 유지해 프리로드 스캐너 이점을 보존하고, 실패했을 때만 onerror 에서 동적 재시도를 건다.
1103 lines
33 KiB
TypeScript
1103 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 { 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 엔드포인트 사용
|
|
let baseUrl: string;
|
|
if (layoutPath.startsWith('__preview__/')) {
|
|
const token = layoutPath.replace('__preview__/', '');
|
|
baseUrl = `/api/layouts/preview/${token}.json`;
|
|
} else {
|
|
baseUrl = `/api/layouts/${templateId}/${layoutPath}.json`;
|
|
}
|
|
const apiUrl = this.cacheVersion > 0 ? `${baseUrl}?v=${this.cacheVersion}` : baseUrl;
|
|
|
|
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;
|