Files
Gnuboard7/resources/js/core/template-engine/G7CoreGlobals.ts
T
HeuJung 1e89335258 fix(core,extensions): 동적 스크립트 주입 경로 전부에 출처 게이트 적용
레이아웃 scripts[] 에만 있던 원격 스크립트 차단 게이트가 다른 주입 경로에는
없어, 저장측(SafeLayoutExpressions·NoExternalUrls)이 외부 URL 저장을 422 로
막아도 런타임 디스패치 한 번으로 임의 원격 코드가 로드됐다. 저장 검증이
통째로 무의미해지는 상태였고 오류도 경고도 남지 않았다.

판정식을 support/scriptSrcPolicy.ts 로 분리해 런타임 SSoT 를 하나로 두고,
loadScript 액션 · 확장 핸들러 재로드 · 편집기 프리뷰 · 편집기 자산 매니페스트 ·
G7Core.asset.loadScript · PG 플러그인 SDK 주입이 모두 그것을 경유하게 했다.
사본을 두지 않은 이유는 차집합이 그대로 우회로가 되기 때문이다.

- 게이트는 캐시 검사보다 앞에 둔다 (뒤에 두면 이미 로드된 미신뢰 스크립트가
 캐시 히트로 통과한다)
- NoExternalUrls 순회를 9키로 확대 — 번들 레이아웃 601개 사전 스캔으로
 정당한 외부 URL 0건을 확인한 뒤 넓혔다 (저장 회귀 없음)
- callExternal 은 생성자를 참조 동일성으로 거부하고(별칭 전역 포함),
 프로토타입 경로 세그먼트를 읽기·매핑 키 양쪽에서 차단한다
- PG SDK 는 서비스 SDK 라 자체 호스팅이 불가능하므로 manifest 선언 +
 주입 직전 호스트 확인이 게이트다. tosspayments SDK URL 은 확장자가 없어
 정적 검사에 걸리지 않아 그 런타임 확인이 유일한 게이트다
- 함께 드러난 결함: 동시 loadScript 가 로드 전에 완료 처리되던 문제,
 확장 재로드에서 script 기존재가 CSS 까지 건너뛰던 문제, loadCSS 의
 in-flight 미공유
2026-09-02 21:43:13 +09:00

4343 lines
156 KiB
TypeScript

/**
* NOTE: @since versions (engine-v1.x.x) refer to internal template engine
* development iterations, not G7 platform release versions.
*/
/**
* G7Core 전역 객체 초기화 모듈
*
* 템플릿 컴포넌트에서 사용할 수 있는 G7Core 전역 API를 노출합니다.
* template-engine.ts에서 분리되어 단일 책임 원칙을 따릅니다.
*
* @packageDocumentation
*/
import React from 'react';
import ReactDOM from 'react-dom/client';
import { createPortal } from 'react-dom';
import * as ReactJSXRuntime from 'react/jsx-runtime';
import { ComponentRegistry } from './ComponentRegistry';
import { TranslationEngine, TranslationContext } from './TranslationEngine';
import { ActionDispatcher } from './ActionDispatcher';
import { DataBindingEngine, dataBindingEngine } from './DataBindingEngine';
import { hasPipes } from './PipeRegistry';
import { extractSingleBinding } from './BindingShape';
import { evaluateStringCondition } from './helpers/ConditionEvaluator';
import { addMissingLeafKeys } from './helpers/StateMerge';
import { DataSourceManager, dataSourceManager } from './DataSourceManager';
import DynamicRenderer from './DynamicRenderer';
import { useTransitionState } from './TransitionContext';
import { useTranslation, TranslationProvider, TranslationReactContext } from './TranslationContext';
import { useResponsive, ResponsiveProvider, ResponsiveContext } from './ResponsiveContext';
import { responsiveManager, BREAKPOINT_PRESETS } from './ResponsiveManager';
import { AuthManager } from '../auth/AuthManager';
import { getApiClient } from '../api/ApiClient';
import { createLogger, flushEarlyLogs } from '../utils/Logger';
import { WebSocketManager } from '../websocket/WebSocketManager';
import { G7DevToolsCore } from '../devtools/G7DevToolsCore';
// DiagnosticEngine/ServerConnector/StyleTracker/DevToolsPanel 은 정적 import 하지 않는다 —
// 디버그 전용 무거운 모듈은 별도 lazy 번들(devtools.min.js)로 분리되어 initDevToolsAPI() 가
// isEnabled() 참일 때만 런타임 <script> 주입으로 로드한다. @since engine-v1.51.0
import type { DiagnosticCategory } from '../devtools/types';
import {
renderItemChildren,
createChangeEvent,
createClickEvent,
createSubmitEvent,
createKeyboardEvent,
mergeClasses,
conditionalClass,
joinClasses,
} from './helpers';
import type { SlotContextValue } from './SlotContext';
import {
useControllableState,
shallowArrayEqual,
shallowObjectEqual,
} from '../hooks/useControllableState';
import { triggerModalParentUpdate } from './ParentContextProvider';
import { IdentityGuardInterceptor, IDENTITY_REDIRECT_STASH_KEY } from '../identity/IdentityGuardInterceptor';
import { loadScriptWithRetry, loadStylesheetWithRetry } from './networkResilience';
import {
templateAsset,
templateAssetDir,
moduleAsset,
pluginAsset,
convertToCurrentMode,
} from '../support/assetUrl';
import { isAllowedScriptSrc, getTrustedScriptHosts } from '../support/scriptSrcPolicy';
import {
notifyAssetFailure,
drainExternalAssetFailures,
clearAssetFailure,
clearAllAssetFailures,
getAssetFailures,
retryAssetFailures,
} from '../assets/AssetFailureNotice';
const logger = createLogger('G7CoreGlobals');
/**
* G7Core.devTools 인터페이스
*
* 템플릿 컴포넌트와 헬퍼 함수에서 DevTools 추적 기능에 접근하기 위한 통합 인터페이스입니다.
* G7DevToolsCore.getInstance() 직접 호출 대신 이 인터페이스를 사용합니다.
*
* @example
* ```ts
* // renderItemChildren 등에서 사용
* if (G7Core.devTools?.isEnabled()) {
* G7Core.devTools.trackIteration(id, source, itemVar, indexVar, itemCount);
* }
* ```
*/
export interface G7DevToolsInterface {
/** DevTools 활성화 상태 확인 */
isEnabled(): boolean;
/** 렌더링 추적 */
trackRender(componentName: string): void;
/** iteration 추적 */
trackIteration(
id: string,
source: string,
itemVar: string,
indexVar: string | undefined,
itemCount: number
): void;
/** 조건부 렌더링 추적 */
trackIfCondition(
id: string,
condition: string,
result: boolean,
componentName?: string
): void;
/** 컴포넌트 마운트 추적 */
trackMount(componentId: string, info: { name: string; type: string; props?: Record<string, any>; parentId?: string }): void;
/** 컴포넌트 언마운트 추적 */
trackUnmount(componentId: string): void;
/** 표현식 평가 추적 */
trackExpressionEval(info: {
expression: string;
result: any;
resultType: string;
duration?: number;
method: 'resolve' | 'resolveBindings' | 'evaluateExpression';
componentId?: string;
componentName?: string;
propName?: string;
fromCache: boolean;
skipCache?: boolean;
steps?: Array<{
step: 'parse' | 'resolve' | 'evaluate' | 'cache';
input?: string;
output?: any;
path?: string;
value?: any;
fromCache?: boolean;
error?: string;
}>;
}): void;
/** 바인딩 평가 추적 */
trackBindingEval(expression?: string): void;
/** 캐시 히트 추적 */
recordCacheHit(): void;
/** 캐시 미스 추적 */
recordCacheMiss(): void;
/** 핸들러 등록 추적 */
trackHandlerRegistration(
name: string,
category: 'built-in' | 'custom' | 'module' | 'plugin',
description?: string,
source?: string
): void;
/** 핸들러 해제 추적 */
trackHandlerUnregistration(name: string): void;
/** 액션 로그 기록 */
logAction(action: {
id: string;
type: string;
params?: Record<string, any>;
resolvedParams?: Record<string, any>;
context?: Record<string, any>;
startTime: number;
endTime?: number;
duration?: number;
status: 'started' | 'success' | 'error';
result?: any;
error?: { name: string; message: string; stack?: string };
children?: Array<any>;
}): void;
/** 네트워크 요청 시작 추적 */
trackRequest(
url: string,
method: string,
options?: { requestBody?: any; dataSourceId?: string },
): string;
/** 네트워크 요청 완료 추적 */
completeRequest(requestId: string, statusCode: number, response?: any): void;
/** 네트워크 요청 실패 추적 */
failRequest(requestId: string, error: string): void;
/** 데이터소스 정의 추적 */
trackDataSourceDefinition(info: {
id: string;
type: 'api' | 'static' | 'route_params' | 'query_params' | 'websocket';
endpoint?: string;
method?: string;
autoFetch?: boolean;
initLocal?: any;
initGlobal?: any;
}): void;
/** 데이터소스 로딩 시작 추적 */
trackDataSourceLoading(id: string): void;
/** 데이터소스 로드 완료 추적 */
trackDataSourceLoaded(id: string, data: any, dataPath?: string): void;
/** 데이터소스 로드 실패 추적 */
trackDataSourceError(id: string, error: string): void;
/** Form 추적 */
trackForm(id: string, dataKey: string, inputs: Array<{ name: string; type: string; binding?: string }>): void;
/** Form 제거 추적 */
untrackForm(id: string): void;
/** setState 렌더링 상관관계 추적 - 시작 */
startStateChange(
statePath: string,
oldValue: any,
newValue: any,
trigger?: { actionId?: string; handlerType?: string; source?: string }
): string;
/** setState 렌더링 상관관계 추적 - 완료 */
completeStateChange(setStateId: string): void;
/** 컴포넌트 렌더링 추적 (상태 변경 컨텍스트 내) */
trackComponentRender(
componentId: string,
componentName: string,
renderDuration: number,
accessedStatePaths?: string[],
evaluatedBindings?: string[],
parentId?: string
): void;
/** _local 상태 업데이트 */
updateLocalState(localState: Record<string, any>): void;
/** _computed 상태 업데이트 */
updateComputedState(computedState: Record<string, any>): void;
/** $parent 컨텍스트 업데이트 (모달/자식 레이아웃에서 부모 상태 접근용) */
updateParentContext(parentContext: Record<string, any> | undefined): void;
/** 컴포넌트 상태 소스 추적 (g7-state-hierarchy) */
trackComponentStateSource?(
componentId: string,
componentName: string,
stateSource: {
global: string[];
local: string[];
context: string[];
},
stateProvider: {
type: 'globalState' | 'dynamicState' | 'componentContext';
hasComponentContext: boolean;
parentId?: string;
}
): void;
/** dynamicState 추적 (g7-state-hierarchy) */
trackDynamicState?(componentId: string, dynamicState: Record<string, any>): void;
/** 컨텍스트 플로우 추적 (g7-context-flow) */
trackContextFlow?(
componentId: string,
componentName: string,
contextReceived: boolean,
passedToChildren: boolean,
usedInRender: boolean,
parentId?: string
): void;
/** 컴포넌트 스타일 추적 (g7-styles) */
trackComponentStyle?(
componentId: string,
componentName: string,
classes: string[],
computedStyles: {
display: string;
visibility: string;
opacity: string;
position: string;
zIndex: string;
overflow: string;
width: string;
height: string;
backgroundColor: string;
color: string;
}
): void;
/** 인증 이벤트 추적 (g7-auth) */
trackAuthEvent?(
type: 'login' | 'logout' | 'token-refresh' | 'token-expired' | 'session-restored' | 'permission-denied' | 'api-unauthorized',
success: boolean,
error?: string,
details?: Record<string, any>
): void;
/** 인증 헤더 추적 (g7-auth) */
trackAuthHeader?(
url: string,
hasAuthHeader: boolean,
headerType?: string,
tokenValid?: boolean,
responseStatus?: number
): void;
/** 로그 추적 (g7-logs) */
trackLog?(
level: 'log' | 'warn' | 'error' | 'debug' | 'info',
prefix: string,
args: unknown[]
): void;
/**
* 액션 실행 추적 (debounce 지원)
*
* debounce 옵션이 있는 액션의 실행 상태를 추적합니다.
*/
trackAction?(info: {
handler: string;
type: string;
status: 'pending' | 'success' | 'error' | 'cancelled';
params?: Record<string, any>;
debounce?: {
delay: number;
status: 'pending' | 'executed' | 'cancelled';
scheduledAt?: number;
executedAt?: number;
mode?: 'leading' | 'trailing';
};
}): void;
/**
* 데이터소스 업데이트 추적 (부분 업데이트 지원)
*
* G7Core.dataSource.updateItem() 호출을 추적합니다.
*/
trackDataSourceUpdate?(info: {
dataSourceId: string;
updateType: 'full' | 'partial';
itemPath?: string;
itemId?: string | number;
updates?: Record<string, any>;
timestamp: number;
}): void;
// ============================================
// Sequence 실행 추적 메서드 (g7-sequence)
// ============================================
/**
* Sequence 실행 시작
*
* @param trigger 트리거 정보
* @returns 생성된 sequenceId
*/
startSequenceExecution?(trigger?: {
eventType?: string;
source?: string;
callbackType?: 'onSuccess' | 'onError';
}): string;
/**
* Sequence 내 액션 실행 전 상태 캡처
*
* @param sequenceId Sequence ID
* @param actionIndex 액션 인덱스
* @param handler 핸들러 이름
* @param params 액션 파라미터
* @param currentState 현재 상태
*/
captureSequenceActionBefore?(
sequenceId: string,
actionIndex: number,
handler: string,
params: Record<string, any>,
currentState: {
_global: Record<string, any>;
_local: Record<string, any>;
_isolated?: Record<string, any>;
}
): void;
/**
* Sequence 내 액션 실행 후 상태 캡처
*
* @param sequenceId Sequence ID
* @param actionIndex 액션 인덱스
* @param currentState 현재 상태
* @param duration 소요 시간
* @param result 액션 결과
* @param error 에러 (있는 경우)
*/
captureSequenceActionAfter?(
sequenceId: string,
actionIndex: number,
currentState: {
_global: Record<string, any>;
_local: Record<string, any>;
_isolated?: Record<string, any>;
},
duration: number,
result?: any,
error?: Error
): void;
/**
* Sequence 실행 완료
*
* @param sequenceId Sequence ID
* @param error 전체 에러 (있는 경우)
*/
endSequenceExecution?(sequenceId: string, error?: Error): void;
// ============================================
// Stale Closure 감지 메서드 (g7-stale-closure)
// ============================================
/**
* 상태 캡처 등록 (비동기 핸들러 시작 시)
*
* @param handlerId 핸들러 ID
* @param statePaths 캡처할 상태 경로들
* @param stateValues 현재 상태 값들
*/
registerStateCaptureForHandler?(
handlerId: string,
statePaths: string[],
stateValues: Record<string, any>
): void;
/**
* Stale Closure 감지 (비동기 작업 완료 후)
*
* @param handlerId 핸들러 ID
* @param location 발생 위치
* @param currentState 현재 상태
* @param warningType 경고 유형
* @param actionId 관련 액션 ID
*/
detectStaleClosure?(
handlerId: string,
location: string,
currentState: Record<string, any>,
warningType: 'async-state-capture' | 'callback-state-capture' | 'timeout-state-capture' | 'sequence-state-mismatch' | 'event-handler-stale',
actionId?: string
): any[];
/**
* Stale Closure 경고 직접 추가
*
* @param info 경고 정보
*/
trackStaleClosureWarning?(info: {
type: 'async-state-capture' | 'callback-state-capture' | 'timeout-state-capture' | 'sequence-state-mismatch' | 'event-handler-stale';
location: string;
capturedPath: string;
capturedValue: any;
capturedAt: number;
currentValue: any;
actionId?: string;
stackTrace?: string;
}): void;
// ============================================
// Modal State Scope 추적 메서드
// ============================================
/**
* 모달 열림 추적
*
* @param options 모달 정보
*/
trackModalOpen?(options: {
modalId: string;
modalName: string;
scopeType?: 'isolated' | 'shared' | 'inherited';
parentModalId?: string;
componentId?: string;
initialState?: Record<string, any>;
isolatedStateKeys?: string[];
sharedStateKeys?: string[];
}): void;
/**
* 모달 닫힘 추적
*
* @param modalId 모달 ID
* @param finalState 최종 상태 (선택)
*/
trackModalClose?(modalId: string, finalState?: Record<string, any>): void;
/**
* 모달 내 상태 변경 추적
*
* @param options 상태 변경 정보
*/
trackModalStateChange?(options: {
modalId: string;
stateKey: string;
previousValue: any;
newValue: any;
changeSource?: 'user-action' | 'api-response' | 'parent-sync' | 'init' | 'cleanup';
}): void;
// ============================================
// Nested Context 추적 메서드
// ============================================
/**
* Nested Context 추적 (expandChildren, cellChildren, iteration, modal, slot)
*
* @param info 컨텍스트 정보
* @returns 생성된 컨텍스트 ID
*/
trackNestedContext?(info: {
componentId: string;
componentType: 'expandChildren' | 'cellChildren' | 'iteration' | 'modal' | 'slot';
parentContext: {
available: string[];
values: Record<string, any>;
};
ownContext: {
added: string[];
values: Record<string, any>;
};
depth: number;
parentId?: string;
}): string;
/**
* Nested Context 접근 시도 기록
*
* @param contextId 컨텍스트 ID
* @param access 접근 정보
*/
trackNestedContextAccess?(contextId: string, access: {
path: string;
found: boolean;
value?: any;
error?: string;
}): void;
// ============================================
// Computed 추적 메서드
// ============================================
/**
* Computed 속성 등록
*
* @param name 속성명
* @param expression 표현식
* @param dependencies 의존성 배열
* @param value 현재 값
* @param computationTime 계산 시간
* @param componentId 컴포넌트 ID (선택)
* @param error 에러 메시지 (선택)
*/
trackComputedProperty?(
name: string,
expression: string,
dependencies: Array<{ type: string; path: string; value?: any }>,
value: any,
computationTime: number,
componentId?: string,
error?: string
): void;
/**
* Computed 재계산 기록
*
* @param computedName 속성명
* @param trigger 트리거 유형
* @param previousValue 이전 값
* @param newValue 새 값
* @param computationTime 계산 시간
* @param triggeredBy 트리거 정보 (선택)
* @param componentId 컴포넌트 ID (선택)
*/
trackComputedRecalc?(
computedName: string,
trigger: 'dependency-change' | 'manual' | 'init',
previousValue: any,
newValue: any,
computationTime: number,
triggeredBy?: {
type: string;
path: string;
oldValue?: any;
newValue?: any;
},
componentId?: string
): void;
// ============================================
// Named Actions 추적 메서드 (g7-named-actions)
// ============================================
/** named_actions 정의 등록 */
setNamedActionDefinitions?(definitions: Record<string, any>): void;
/** actionRef 해석 이력 기록 */
trackNamedActionRef?(log: {
actionRefName: string;
resolvedHandler: string;
timestamp: number;
}): void;
}
/**
* G7Core 전역 객체 초기화에 필요한 의존성 인터페이스
*/
export interface G7CoreDependencies {
/** 템플릿 엔진 상태 getter */
getState: () => {
translationEngine: TranslationEngine | null;
translationContext: TranslationContext;
bindingEngine: DataBindingEngine | null;
actionDispatcher: ActionDispatcher | null;
templateMetadata: { locales: string[] } | null;
};
/** TransitionManager 인스턴스 */
transitionManager: {
getIsPending: () => boolean;
subscribe: (callback: (isPending: boolean) => void) => () => void;
};
/** ResponsiveManager 인스턴스 */
responsiveManager: object;
/** WebSocketManager 인스턴스 */
webSocketManager: WebSocketManager;
/** SlotContext getter (SlotProvider 내부에서 설정) */
getSlotContext?: () => SlotContextValue | null;
/** DynamicRenderer 컴포넌트 getter */
getDynamicRenderer?: () => React.FC<any> | null;
/** ComponentRegistry getter */
getComponentRegistry?: () => any;
/** DataBindingEngine getter */
getDataBindingEngine?: () => DataBindingEngine | null;
/** TranslationEngine getter */
getTranslationEngine?: () => TranslationEngine | null;
/** ActionDispatcher getter */
getActionDispatcher?: () => ActionDispatcher | null;
}
/**
* React 라이브러리를 전역으로 노출
*
* 템플릿 번들에서 React를 사용할 수 있도록 합니다.
*/
function initReactGlobals(): void {
// React/ReactDOM/ReactJSXRuntime을 전역으로 노출 (템플릿 번들에서 사용)
(window as any).React = React;
// React 19에서 unstable_batchedUpdates가 제거되어 @dnd-kit 등에서 에러 발생
// no-op 폴리필을 제공하여 호환성 유지 (React 19에서는 자동 배칭이 기본값)
(window as any).ReactDOM = {
...ReactDOM,
createPortal,
unstable_batchedUpdates: (callback: () => void) => callback(),
};
(window as any).ReactJSXRuntime = ReactJSXRuntime;
logger.log('전역 객체 window.React, window.ReactDOM, window.ReactJSXRuntime에 노출됨');
}
/**
* 컴포넌트 이벤트 시스템 초기화
*
* 컴포넌트 간 통신을 위한 이벤트 시스템을 제공합니다.
*/
function initComponentEventSystem(G7Core: any): void {
const componentEventListeners = new Map<string, Set<(data?: any) => void | Promise<any>>>();
const devTools = G7DevToolsCore.getInstance();
G7Core.componentEvent = {
/**
* 컴포넌트 이벤트를 구독합니다.
* @param eventName 이벤트명 (예: 'triggerUpload:site_logo_uploader')
* @param callback 이벤트 발생 시 호출될 콜백
* @returns 구독 해제 함수
*/
on: (eventName: string, callback: (data?: any) => void | Promise<any>) => {
if (!componentEventListeners.has(eventName)) {
componentEventListeners.set(eventName, new Set());
}
componentEventListeners.get(eventName)!.add(callback);
// DevTools 추적
devTools.trackEventSubscribe(eventName);
return () => {
componentEventListeners.get(eventName)?.delete(callback);
// DevTools 추적
devTools.trackEventUnsubscribe(eventName);
};
},
/**
* 컴포넌트 이벤트를 발생시킵니다.
* @param eventName 이벤트명
* @param data 전달할 데이터
* @returns 모든 리스너의 실행 결과 Promise 배열
*/
emit: async (eventName: string, data?: any): Promise<any[]> => {
const listeners = componentEventListeners.get(eventName);
const listenerCount = listeners?.size ?? 0;
if (!listeners || listeners.size === 0) {
// DevTools 추적 (리스너 없음)
devTools.trackEventEmit(eventName, data, 0);
return [];
}
let emitError: Error | undefined;
let results: any[] = [];
try {
results = await Promise.all(
Array.from(listeners).map(async (callback) => {
try {
return await callback(data);
} catch (error) {
logger.error(`componentEvent: Error in listener for "${eventName}":`, error);
throw error;
}
})
);
} catch (error) {
emitError = error instanceof Error ? error : new Error(String(error));
}
// DevTools 추적
devTools.trackEventEmit(eventName, data, listenerCount, results, emitError);
if (emitError) {
throw emitError;
}
return results;
},
/**
* 특정 이벤트의 모든 리스너를 제거합니다.
* @param eventName 이벤트명
*/
off: (eventName: string) => {
componentEventListeners.delete(eventName);
// DevTools 추적
devTools.trackEventOff(eventName);
},
/**
* 모든 이벤트 리스너를 제거합니다.
*/
clear: () => {
componentEventListeners.clear();
// DevTools 추적
devTools.trackEventClear();
},
};
logger.log('전역 객체 window.G7Core.componentEvent에 노출됨');
}
/**
* 코어 런타임 표면을 `window.G7Core.__runtime` 으로 노출 — 편집기 lazy 번들 공유용.
*
* @since engine-v1.51.0
*
* 레이아웃 편집기 셸은 별도 번들(`layout-editor.min.js`)로 지연 로드된다(메인 번들 비대화
* 회피). 편집기는 코어 런타임의 렌더러/엔진/컨텍스트를 재사용해야 하는데, 이때 **동일 인스턴스
* 동일성**이 강제된다:
*
* - `getInstance()` 싱글톤(TranslationEngine 등)을 편집기 번들이 재번들하면 편집기 사본의
* 싱글톤이 메인 번들과 fork → 번역/데이터소스 상태가 어긋난다.
* - React Context(TranslationReactContext/ResponsiveContext 등)를 재번들하면 `createContext()`
* 가 두 번 실행돼 Context 객체 참조가 달라짐 → 편집기의 `useContext` 가 빈 값을 읽는다.
* - `DynamicRenderer` 는 사실상 코어 런타임 전체(ActionDispatcher/DataBindingEngine/모든
* Context/sortable)를 끌어오므로, 재번들 시 코어 대부분이 편집기 번들에 중복된다.
*
* 이를 막기 위해 메인 번들이 이 런타임 표면을 `G7Core.__runtime` 에 노출하고, 편집기 빌드는
* `resolve.alias` 로 `../DynamicRenderer` 등 상대 경로를 `G7Core.__runtime` 재export shim 으로
* 치환한다(편집기 소스는 무수정). 결과적으로 편집기 번들은 코어 런타임을 0바이트 중복으로
* **빌려 쓰고**, 싱글톤/컨텍스트 동일성이 자동 보장된다.
*
* 메인 `<script>` 는 동기 선행 실행되므로, 편집기 IIFE 가 실행될 때 `__runtime` 은 항상 존재한다.
*
* @param G7Core - window.G7Core 전역 객체
*/
function initCoreRuntimeExports(G7Core: any): void {
// 이미 노출돼 있으면 멱등 (중복 init 방지)
if (G7Core.__runtime) return;
// 노출 대상 = 편집기가 layout-editor/ 밖으로 직접 import 하는 11개 모듈의 런타임 값만.
// (Slot/Transition Provider 는 편집기가 직접 import 하지 않고 메인 번들 renderTemplate 이
// wrapping 하므로 __runtime 미포함. DynamicRenderer 내부의 Slot/Transition 의존은 메인 번들
// DynamicRenderer 에 이미 번들돼 있어 별도 공유 불필요.)
G7Core.__runtime = {
// 렌더러 (default export) — 프리뷰 캔버스
DynamicRenderer,
// 엔진/레지스트리 클래스 (getInstance 싱글톤)
ComponentRegistry,
TranslationEngine,
DataSourceManager,
dataSourceManager,
DataBindingEngine,
dataBindingEngine,
ActionDispatcher,
// 번역 컨텍스트 (Context 객체 동일성 필수)
TranslationReactContext,
TranslationProvider,
useTranslation,
// 반응형 컨텍스트/매니저
ResponsiveContext,
ResponsiveProvider,
useResponsive,
responsiveManager,
BREAKPOINT_PRESETS,
// 인증/로거
AuthManager,
createLogger,
// DevTools 추적 코어 (디버그 lazy 번들이 패널/진단엔진에서 공유) @since engine-v1.51.0
G7DevToolsCore,
};
logger.log('전역 객체 window.G7Core.__runtime(코어 런타임 표면)에 노출됨');
}
/**
* `G7Core.layoutEditor` 예약 접수함(ready 큐) 초기화 —.
*
* 레이아웃 편집기 확장점(`registerWidget`/`registerNodeEditor`/`registerCanvasOverlay`)은
* 편집기 셸(lazy 번들)이 로드될 때 `exposeLayoutEditorGlobals` 가 **실제 레지스트리 함수**로
* 노출한다. 그러나 템플릿 `initTemplate` 은 편집기 로드 **이전**에 실행되므로, 그 시점에
* `G7Core.layoutEditor` 가 없으면 등록이 허공으로 사라진다.
*
* 이를 막기 위해 메인 번들에서 **경량 stub** 을 항상 먼저 노출한다. stub 의 register* 는
* 등록 요청을 `__queue` 에 적재만 하고, `onReady(cb)` 는 `__readyCallbacks` 에 예약한다.
* 편집기 셸 로드 시 `exposeLayoutEditorGlobals` 가 실제 함수로 교체하면서 `__queue` 를
* 일괄 flush + `__readyCallbacks` 호출한다(그 후의 호출은 즉시 등록). 이미 실제 API 가
* 노출돼 있으면(편집기 먼저 로드) stub 으로 덮지 않는다.
*
* 메인 번들 추가분은 함수 3개 + 배열 2개뿐(번들 비대화 ~0). 큐/콜백 공유 상태는 코드
* 분할(메인 vs 편집기 번들) 경계를 넘어야 하므로 모듈 변수가 아니라 `G7Core.layoutEditor`
* **객체 자체**(`__queue`/`__readyCallbacks`)에 둔다.
*/
function initLayoutEditorStub(G7Core: any): void {
// 편집기 셸이 먼저 로드돼 실제 API 가 이미 있으면 stub 으로 덮지 않는다.
if (G7Core.layoutEditor && G7Core.layoutEditor.__isStub !== true) return;
if (G7Core.layoutEditor && G7Core.layoutEditor.__isStub === true) return; // 중복 init 멱등
const queue: Array<[string, ...unknown[]]> = [];
const readyCallbacks: Array<() => void> = [];
G7Core.layoutEditor = {
__isStub: true,
__queue: queue,
__readyCallbacks: readyCallbacks,
registerWidget: (name: string, comp: unknown) => queue.push(['widget', name, comp]),
registerNodeEditor: (kind: string, comp: unknown) => queue.push(['nodeEditor', kind, comp]),
registerCanvasOverlay: (kind: string, overlay: unknown) =>
queue.push(['canvasOverlay', kind, overlay]),
/** 편집기 ready 시 호출될 콜백 예약. 이미 ready 면(=stub 아님) 즉시 호출은 실제 API 책임 */
onReady: (cb: () => void) => {
if (typeof cb === 'function') readyCallbacks.push(cb);
},
};
logger.log('전역 객체 window.G7Core.layoutEditor 예약 접수함(stub) 노출됨');
}
/**
* 자산 URL·자산 실패 안내 API 초기화
*
* 확장(모듈·플러그인·템플릿)의 IIFE 번들은 코어의 `assetUrl.ts` 를 import 할 수 없다
* — 별도 번들이라 모듈 그래프가 이어지지 않는다. 그래서 확장이 자기 동봉 자산의 URL 을
* 만들려면 `/api/plugins/assets/...` 를 **문자열로 조립**하는 수밖에 없었는데, 그러면
* 자산 URL 이중 모드(확장자 없는 서버)에서 그 자산만 404 가 된다.
*
* `G7Core.asset.*` 이 그 seam 이다 — 서버측 `AssetUrl` 과 같은 규약으로 URL 을 만든다.
* `G7Core.assets.*` 는 그 자산을 끝내 못 불러왔을 때의 안내·재시도 표면이다.
*
* @param G7Core 전역 객체
* @return void
* @since engine-v1.62.0
*/
function initAssetUrlAPI(G7Core: any): void {
/**
* 확장 자산 URL 생성기.
*
* 템플릿은 서버가 `dist/` 를 자동 부가하므로 `path` 에 `dist/` 를 포함하지 않는다
* (모듈·플러그인은 확장 루트 기준이라 `dist/` 를 직접 포함 — 서버측과 동일한 비대칭).
*/
G7Core.asset = {
/** 템플릿 자산 URL (`dist/` 이하 경로) */
template: (identifier: string, path: string, version?: number | string | null): string =>
templateAsset(identifier, path, version),
/**
* 템플릿 자산 **디렉토리** URL — AMD 로더·워커처럼 디렉토리 접두에 파일명을
* 이어 붙이는 소비자용. 확장자 없는 모드에서 404 일 수 있으므로 폴백이 필요하다.
*/
templateDir: (identifier: string, path: string): string => templateAssetDir(identifier, path),
/** 모듈 자산 URL (모듈 루트 기준 경로) */
module: (identifier: string, path: string, version?: number | string | null): string =>
moduleAsset(identifier, path, version),
/** 플러그인 자산 URL (플러그인 루트 기준 경로) */
plugin: (identifier: string, path: string, version?: number | string | null): string =>
pluginAsset(identifier, path, version),
/** 서버가 확장자 형태로 굳혀 내려준 URL 을 현재 모드로 보정 */
convertToCurrentMode: (url: string): string => convertToCurrentMode(url),
/**
* 재시도 계층을 갖춘 스크립트 로더.
*
* 확장이 자기 자산을 런타임에 직접 로드할 때 쓴다. 확장 번들이 코어 모듈을
* import 할 수 없어 각자 `document.createElement('script')` 를 쓰면, 코어가
* 갖춘 재시도·실패 표면화 계층이 그 경로에만 없게 된다.
*
* `url` 은 레이아웃 `scripts[]` 와 **같은 출처 정책**을 받는다 — same-origin 절대
* 경로이거나 확장이 manifest(`trusted_script_hosts`)로 선언한 신뢰 호스트여야 한다.
* 이 seam 만 게이트가 없으면 저장측 검증을 우회한 원격 코드 로드 통로가 된다.
*
* @since engine-v1.64.0 출처 게이트 추가 (미신뢰 URL 은 reject)
*/
loadScript: (
url: string,
attrs?: Record<string, string>,
options?: Record<string, unknown>
): Promise<void> => {
if (!isAllowedScriptSrc(url, getTrustedScriptHosts())) {
return Promise.reject(
new Error(
`Blocked untrusted script src (same-origin path or declared trusted host required): ${url}`
)
);
}
return loadScriptWithRetry(url, attrs, options as any);
},
/**
* 스크립트 URL 이 주입 허용 대상인지 판정합니다.
*
* 로더를 쓸 수 없는 주입(iframe `document.write` 등)이 같은 판정을 재사용하는 통로다.
*
* @since engine-v1.64.0
*/
isAllowedScriptSrc: (url: string): boolean =>
isAllowedScriptSrc(url, getTrustedScriptHosts()),
/** 재시도 계층을 갖춘 스타일시트 로더 */
loadStylesheet: (
url: string,
attrs?: Record<string, string>,
options?: Record<string, unknown>
): Promise<void> => loadStylesheetWithRetry(url, attrs, options as any),
};
/**
* 자산 로드 실패 안내.
*
* 실패를 화면에 표면화하지 않으면 사용자에게는 "빈 자리" 로만 나타나고, 자체 서버
* 로그에도 흔적이 남지 않아 운영자가 원인을 특정할 수 없다.
*/
G7Core.assets = {
notifyFailure: notifyAssetFailure,
clearFailure: clearAssetFailure,
clearAll: clearAllAssetFailures,
getFailures: getAssetFailures,
retryAll: retryAssetFailures,
};
// 서버가 심은 템플릿 externals 는 엔진보다 먼저 평가되므로 실패가 대기열에 쌓여 있다.
// 여기서 비우지 않으면 아이콘 폰트·글꼴 실패가 화면에 영영 드러나지 않는다.
drainExternalAssetFailures();
logger.log('전역 객체 window.G7Core.asset / window.G7Core.assets 노출됨');
}
/**
* 번역 관련 API 초기화
*/
function initTranslationAPI(G7Core: any, deps: G7CoreDependencies): void {
// useTranslation 훅 노출 (템플릿 컴포넌트에서 다국어 번역 함수 접근용)
G7Core.useTranslation = useTranslation;
/**
* G7Core.t() - 다국어 번역 함수 직접 접근
*
* 컴포넌트에서 훅 없이 간편하게 번역 함수를 사용할 수 있습니다.
*
* @example
* ```tsx
* // 간단한 번역
* const text = G7Core.t('common.confirm');
*
* // 파라미터를 포함한 번역
* const message = G7Core.t('admin.users.pagination_info', { from: 1, to: 10, total: 100 });
* ```
*
* @param key 번역 키 (예: 'common.confirm')
* @param params 번역 파라미터 (선택, 예: { count: 5 })
* @returns 번역된 문자열, 번역이 없으면 키 자체 반환
*/
G7Core.t = (key: string, params?: Record<string, string | number>): string => {
const state = deps.getState();
// 템플릿 엔진이 초기화되지 않은 경우 키 자체 반환
if (!state.translationEngine || !state.translationContext) {
return key;
}
if (params) {
// 파라미터를 TranslationEngine 형식으로 변환 (|key=value|key2=value2)
const paramsStr = '|' + Object.entries(params).map(([k, v]) => `${k}=${v}`).join('|');
return state.translationEngine.translate(key, state.translationContext, paramsStr);
}
return state.translationEngine.translate(key, state.translationContext);
};
logger.log('전역 객체 window.G7Core.useTranslation에 노출됨');
}
/**
* 이벤트 헬퍼 및 렌더링 헬퍼 초기화
*/
function initHelperAPIs(G7Core: any, deps: G7CoreDependencies): void {
// 이벤트 헬퍼 노출 (템플릿 컴포넌트에서 ActionDispatcher 호환 이벤트 생성용)
G7Core.createChangeEvent = createChangeEvent;
G7Core.createClickEvent = createClickEvent;
G7Core.createSubmitEvent = createSubmitEvent;
G7Core.createKeyboardEvent = createKeyboardEvent;
// UUID 생성 함수 노출 (임시 키 등 고유 식별자 생성용)
// 사용법: G7Core.uuid() → "550e8400-e29b-41d4-a716-446655440000"
// 레이아웃 표현식에서: {{$uuid()}}
G7Core.uuid = (): string => {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
// fallback: crypto.randomUUID 미지원 환경
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
};
// Logger 팩토리 노출 (모듈/템플릿에서 일관된 로깅을 위해)
G7Core.createLogger = createLogger;
logger.log('전역 객체 window.G7Core.createLogger에 노출됨');
// renderItemChildren 노출 (반복 아이템의 자식 컴포넌트 렌더링용)
// CardGrid 등에서 호출 시 translationContext, translationEngine, bindingEngine, actionDispatcher를 자동 주입
// 전역 상태(_global, _local, _computed)도 컨텍스트에 자동 병합하여 cellChildren에서 접근 가능
G7Core.renderItemChildren = (
children: any[],
itemContext: Record<string, any>,
componentMap: Record<string, any>,
keyPrefix?: string,
options?: any
) => {
const state = deps.getState();
// 전역 상태 가져오기
// TemplateApp.getGlobalState()는 globalState를 직접 반환 (예: { _remountKeys, sidebarOpen, ... })
// 이 값이 dataContext._global의 내용과 동일함
const templateApp = (window as any).__templateApp;
const globalStateContent = templateApp?.getGlobalState?.() || {};
// 전역 상태를 컨텍스트에 자동 병합 (cellChildren에서 _global 접근 가능)
// itemContext에 이미 해당 키가 있으면 itemContext 값 우선 (명시적 전달 존중)
// _local, _computed는 itemContext에서 전달받거나 비어있음
const mergedContext = {
_global: globalStateContent, // globalState 내용이 곧 _global
_local: itemContext._local || {},
_computed: itemContext._computed || {},
...itemContext,
};
// remount 지원: _global._remountKeys를 확인하여 컴포넌트 리마운트를 지원하는 키 생성 함수
// 적용된 remountId를 추적하여 중복 적용 방지
const appliedRemountIds = new Set<string>();
const getRemountKey = (componentId: string | undefined, fallbackKey: string): string => {
const remountKeys = mergedContext._global?._remountKeys;
if (componentId && remountKeys?.[componentId] && !appliedRemountIds.has(componentId)) {
appliedRemountIds.add(componentId);
return `${componentId}-remount-${remountKeys[componentId]}`;
}
return componentId || fallbackKey;
};
// options가 없거나 필요한 엔진이 없으면 현재 state에서 자동 주입
const mergedOptions = {
translationContext: state.translationContext,
translationEngine: state.translationEngine,
bindingEngine: state.bindingEngine,
actionDispatcher: state.actionDispatcher,
getRemountKey, // remount 지원 추가
...options,
};
return renderItemChildren(children, mergedContext, componentMap, keyPrefix, mergedOptions);
};
/**
* 조건 문자열 평가 노출 (템플릿 컴포넌트용)
*
* 템플릿 컴포넌트가 `new Function('row', ...)` 로 자체 평가기를 두면 엔진과 판정이
* 갈린다 — 실제로 `_local`/`_global` 을 참조하는 조건에서 두 컴포넌트가 예외를 서로
* 다르게 처리해 한쪽은 자식이 사라지고 다른 쪽은 항상 표시되는 **반대 방향** 결과가 났다.
* 이 API 는 엔진의 `evaluateStringCondition` 을 그대로 쓰고, 전역/로컬/계산 상태를
* 컨텍스트에 병합해 넘긴다.
*
* @param condition 조건 문자열 (예: `"{{row.charge_policy !== 'free'}}"`)
* @param extraContext 추가 컨텍스트 (예: `{ row }`)
* @returns 평가 결과. 조건이 비어 있으면 true
*
* @since engine-v1.56.1
*/
G7Core.evaluateCondition = (condition: string, extraContext?: Record<string, any>): boolean => {
if (!condition) return true;
const state = deps.getState();
const templateApp = (window as any).__templateApp;
const globalStateContent = templateApp?.getGlobalState?.() || {};
const context = {
_global: globalStateContent,
_local: globalStateContent._local || {},
_computed: globalStateContent._computed || {},
...(extraContext || {}),
};
const engine = state.bindingEngine ?? dataBindingEngine;
return evaluateStringCondition(condition, context, engine);
};
// getComponentMap 노출 (CardGrid 등에서 전체 컴포넌트 맵 접근용)
G7Core.getComponentMap = () => {
return ComponentRegistry.getInstance().getComponentMap();
};
/**
* component_layout을 렌더링하는 헬퍼 함수
*
* 컴포넌트에서 component_layout 정의를 받아서 React 요소로 렌더링합니다.
* 각 항목의 컨텍스트(item, index 등)를 주입하여 데이터 바인딩을 처리합니다.
*
* @param layoutDefs component_layout 정의 배열
* @param itemContext 항목별 컨텍스트 (예: { item: option, index: 0, isSelected: true })
* @param keyPrefix React key prefix
* @returns React 요소 배열
*
* @example
* ```tsx
* // RichSelect 컴포넌트에서 사용
* const itemElements = G7Core.renderComponentLayout(
* __componentLayoutDefs?.item,
* { item: option, index, isSelected },
* `item-${index}`
* );
* ```
*/
G7Core.renderComponentLayout = (
layoutDefs: any[] | undefined,
itemContext: Record<string, any>,
keyPrefix?: string
): React.ReactNode => {
if (!layoutDefs || !Array.isArray(layoutDefs) || layoutDefs.length === 0) {
return null;
}
const componentMap = ComponentRegistry.getInstance().getComponentMap();
const state = deps.getState();
// 전역 상태 가져오기 (remount 지원을 위해)
const templateApp = (window as any).__templateApp;
const globalState = templateApp?.getGlobalState?.() || {};
// 전역 상태를 컨텍스트에 병합 (remount 키 접근을 위해)
const mergedContext = {
_global: globalState._global || {},
_local: globalState._local || {},
_computed: globalState._computed || {},
...itemContext,
};
// remount 지원
const appliedRemountIds = new Set<string>();
const getRemountKey = (componentId: string | undefined, fallbackKey: string): string => {
const remountKeys = mergedContext._global?._remountKeys;
if (componentId && remountKeys?.[componentId] && !appliedRemountIds.has(componentId)) {
appliedRemountIds.add(componentId);
return `${componentId}-remount-${remountKeys[componentId]}`;
}
return componentId || fallbackKey;
};
return renderItemChildren(
layoutDefs,
mergedContext,
componentMap,
keyPrefix,
{
translationContext: state.translationContext,
translationEngine: state.translationEngine,
bindingEngine: state.bindingEngine,
actionDispatcher: state.actionDispatcher,
getRemountKey,
}
);
};
/**
* 확장 행(expandChildren) 컨텐츠를 렌더링하는 헬퍼 함수 (Phase 1-2)
*
* DataGrid, CardGrid 등에서 확장 행 렌더링 시 필요한 복잡한 로직을
* 캡슐화하여 60줄+ 코드를 10줄 이하로 줄입니다.
*
* @param config.children 렌더링할 컴포넌트 정의 배열 (expandChildren)
* @param config.row 현재 행 데이터
* @param config.expandContext 확장 컨텍스트 (바인딩 표현식 자동 평가)
* @param config.componentContext 부모 컴포넌트 컨텍스트 (state, setState)
* @param config.componentMap 컴포넌트 맵 (선택, 없으면 자동 조회)
* @param config.keyPrefix React key prefix
* @returns React 요소 배열
*
* @example
* ```tsx
* // DataGrid에서 확장 행 렌더링 (Before: 60줄+ → After: 10줄)
* const renderExpandedContent = useCallback((row: any) => {
* if (expandedRowRender) return expandedRowRender(row);
* if (expandChildren && expandChildren.length > 0) {
* return G7Core.renderExpandContent({
* children: expandChildren,
* row,
* expandContext,
* componentContext: __componentContext,
* keyPrefix: `expand-${row[idField]}`,
* });
* }
* return null;
* }, [expandChildren, expandContext, __componentContext, expandedRowRender, idField]);
* ```
*/
G7Core.renderExpandContent = (config: {
children: any[];
row: any;
expandContext?: Record<string, any>;
componentContext?: {
state?: Record<string, any>;
setState?: (updates: Record<string, any>) => void;
stateRef?: { current: Record<string, any> };
computedRef?: { current: Record<string, any> };
};
componentMap?: Record<string, React.ComponentType<any>>;
keyPrefix: string;
}): React.ReactNode => {
const { children, row, expandContext, componentContext, keyPrefix } = config;
if (!children || !Array.isArray(children) || children.length === 0) {
return null;
}
// 컴포넌트 맵 조회 (없으면 자동)
const componentMap = config.componentMap || ComponentRegistry.getInstance().getComponentMap();
const state = deps.getState();
// 1. 전역 상태 가져오기
const globalState = G7Core.state?.get?.() || {};
// 2. componentContext에서 최신 _local 상태 가져오기
// stateRef.current를 우선 사용하여 캐싱된 componentContext에서도 최신 상태 접근
// stateRef가 없으면 기존 state 사용 (하위 호환성)
const latestLocalState = componentContext?.stateRef?.current ?? componentContext?.state ?? {};
const mergedLocal = {
...(globalState._local || {}),
...latestLocalState,
};
// 3. componentContext에서 최신 _computed 상태 가져오기
// computedRef.current를 우선 사용하여 캐싱된 componentContext에서도 최신 _computed 접근
// _computed는 _local 기반으로 계산되므로 _local 변경 시 함께 업데이트됨
const latestComputed = componentContext?.computedRef?.current ?? globalState._computed ?? {};
// 4. expandContext 바인딩 표현식 자동 평가
const bindingEngine = state.bindingEngine ?? G7Core.getDataBindingEngine?.();
const evalContext = {
...globalState,
_local: mergedLocal,
_computed: latestComputed,
row,
item: row,
$item: row,
};
let resolvedExpandContext: Record<string, any> = {};
if (expandContext && typeof expandContext === 'object' && bindingEngine) {
try {
// expandContext의 각 값을 수동으로 바인딩 평가
// resolveObject는 DEFAULT_SKIP_BINDING_KEYS 때문에 expandContext 내부 키를 스킵할 수 있음
for (const [key, value] of Object.entries(expandContext)) {
if (typeof value === 'string') {
// 단일 Mustache 표현식인지 확인 ({{expr}} 형태).
// 판정은 BindingShape 정본 — 종전 `^\{\{([^}]+)\}\}$` 은 식 안에 `}` 가
// 들어간 경우(`?? {}` 등)를 단일 바인딩으로 보지 못해 보간 경로로 흘렸다.
// @since engine-v1.55.0
const expr = extractSingleBinding(value);
if (expr !== null) {
// 단일 바인딩 표현식: evaluateExpression 사용하여 원본 타입 유지
// resolveBindings는 문자열 보간용이라 배열/객체를 JSON 문자열로 변환함
try {
// 파이프 표현식은 evaluatePipeExpression 으로 평가한다 — evaluateExpression 은
// `|` 를 JS 비트 OR 로 보므로 파이프가 적용되지 않는다. @since engine-v1.54.10
resolvedExpandContext[key] = hasPipes(expr)
? bindingEngine.evaluatePipeExpression(expr, evalContext, { skipCache: true })
: bindingEngine.evaluateExpression(expr, evalContext, { skipCache: true });
} catch (e) {
// 평가 실패 시 undefined (폴백 처리는 표현식 자체에서 || 로 처리)
resolvedExpandContext[key] = undefined;
}
} else {
// 혼합 표현식 (예: "Hello {{name}}"): resolveBindings 사용
resolvedExpandContext[key] = bindingEngine.resolveBindings(value, evalContext, { skipCache: true });
}
} else {
resolvedExpandContext[key] = value;
}
}
} catch (error) {
logger.warn('renderExpandContent: expandContext 평가 오류:', error);
}
}
// 4. 최종 컨텍스트 구성
const context = {
row,
item: row,
$item: row,
_local: mergedLocal,
_global: globalState._global || {},
_computed: globalState._computed || {},
...resolvedExpandContext,
};
// 5. remount 지원
const appliedRemountIds = new Set<string>();
const getRemountKey = (componentId: string | undefined, fallbackKey: string): string => {
const remountKeys = context._global?._remountKeys;
if (componentId && remountKeys?.[componentId] && !appliedRemountIds.has(componentId)) {
appliedRemountIds.add(componentId);
return `${componentId}-remount-${remountKeys[componentId]}`;
}
return componentId || fallbackKey;
};
// 6. 렌더링
return renderItemChildren(children, context, componentMap, keyPrefix, {
translationContext: state.translationContext,
translationEngine: state.translationEngine ?? undefined,
bindingEngine: state.bindingEngine ?? undefined,
actionDispatcher: state.actionDispatcher,
componentContext,
getRemountKey,
});
};
/**
* 깊은 객체 경로 접근 헬퍼 함수
*
* 동적 키를 포함한 깊은 객체 경로 접근과 폴백 값을 지원합니다.
* 다중 통화, 다국어, 중첩 설정 등 다양한 상황에서 활용할 수 있습니다.
*
* @param obj 접근할 객체
* @param path 경로 (문자열 또는 문자열 배열)
* @param fallback 경로가 존재하지 않을 때 반환할 기본값
* @returns 경로에 해당하는 값 또는 폴백 값
*
* @example
* ```ts
* // 다중 통화 가격 접근
* $get(product.prices, [currency, 'formatted'], product.price_formatted)
*
* // 다국어 객체 접근
* $get(product.name, locale, product.name.ko)
*
* // 중첩 설정 접근
* $get(settings, ['drivers', 'mail', 'host'], 'localhost')
*
* // 동적 키 접근
* $get(prices, [selectedPlan, 'monthly'], 0)
* ```
*/
G7Core.$get = function $get(obj: any, path: string | string[], fallback: any = undefined): any {
// null/undefined 객체는 즉시 폴백 반환
if (obj === null || obj === undefined) {
return fallback;
}
// 경로를 배열로 정규화
const keys = Array.isArray(path) ? path : [path];
// 빈 경로는 객체 자체 반환
if (keys.length === 0) {
return obj;
}
let current = obj;
for (const key of keys) {
// 현재 위치가 null/undefined이면 폴백 반환
if (current === null || current === undefined) {
return fallback;
}
// 키가 null/undefined이면 폴백 반환
if (key === null || key === undefined) {
return fallback;
}
// 다음 레벨로 이동
current = current[key];
}
// 최종 값이 null/undefined이면 폴백 반환
return current ?? fallback;
};
logger.log('전역 객체 window.G7Core.renderItemChildren에 노출됨');
logger.log('전역 객체 window.G7Core.renderComponentLayout에 노출됨');
logger.log('전역 객체 window.G7Core.renderExpandContent에 노출됨');
logger.log('전역 객체 window.G7Core.$get에 노출됨');
}
/**
* 액션 디스패치 API 초기화
*/
function initDispatchAPI(G7Core: any): void {
/**
* 액션을 디스패치합니다.
*
* @param action 실행할 액션 정의
* @param options 디스패치 옵션
* @param options.componentContext 컴포넌트 컨텍스트 (openModal 등에서 부모 컨텍스트 지정용)
* - state: 컴포넌트의 _local 상태
* - setState: 컴포넌트의 상태 업데이트 함수
* - 지정 시 __g7ActionContext보다 우선 사용됨
*
* @example
* // 기본 사용 (레이아웃 액션에서 자동으로 컨텍스트 설정됨)
* G7Core.dispatch({ handler: 'openModal', target: 'myModal' });
*
* // 컴포넌트에서 직접 호출 시 컨텍스트 명시적 전달
* G7Core.dispatch(
* { handler: 'openModal', target: 'myModal' },
* { componentContext: props.__componentContext }
* );
*/
G7Core.dispatch = async (action: any, options?: { componentContext?: any }) => {
const templateApp = (window as any).__templateApp;
if (!templateApp) {
logger.warn('G7Core.dispatch: TemplateApp이 초기화되지 않았습니다.');
return { success: false, error: new Error('TemplateApp이 초기화되지 않았습니다.') };
}
const actionDispatcher = templateApp.getActionDispatcher?.();
if (!actionDispatcher) {
logger.warn('G7Core.dispatch: ActionDispatcher를 찾을 수 없습니다.');
return { success: false, error: new Error('ActionDispatcher를 찾을 수 없습니다.') };
}
const router = templateApp.getRouter?.();
const globalState = templateApp.getGlobalState?.();
const setGlobalState = templateApp.setGlobalState?.bind(templateApp);
// engine-v1.17.3: 컴포넌트에서 직접 dispatch 호출 시 componentContext 전달 지원
// openModal 등에서 부모 컨텍스트를 정확히 지정하기 위해 사용
// 우선순위: options.componentContext > __g7ActionContext > global fallback
const passedComponentContext = options?.componentContext;
// 현재 실행 중인 액션의 컨텍스트가 있으면 이를 우선 사용
// 핸들러 내부에서 G7Core.modal.open() 등을 호출할 때 컴포넌트 컨텍스트 유지
// engine-v1.16.0: $parent 바인딩 컨텍스트 지원을 위해 필수
const actionContext = passedComponentContext || (window as any).__g7ActionContext;
// engine-v1.17.0: setLocal 후 즉시 dispatch 호출 시 최신 로컬 상태 참조 지원
// React setState는 비동기이므로 actionContext.state에 반영되기 전에 dispatch가 호출될 수 있음
// __g7PendingLocalState가 있으면 이를 context.state로 사용하여 최신 상태 보장
const pendingLocalState = (window as any).__g7PendingLocalState;
let contextState = actionContext?.state ?? globalState;
// pending 상태가 있으면 context.state에 병합
// 커스텀 핸들러에서 setLocal 후 dispatch 호출 시 {{_local.xxx}} 표현식에서 최신 값 참조
if (pendingLocalState && actionContext) {
contextState = pendingLocalState;
logger.log('[dispatch] Using __g7PendingLocalState for context.state:', pendingLocalState);
}
// v1.19.1: pendingLocalState가 있으면 context.data._local도 갱신
// refetchDataSource 핸들러에서 context.data._local을 contextLocalState로 우선 참조하므로 (v1.19.0)
// 커스텀 핸들러에서 setLocal → dispatch 호출 시 context.data._local이 렌더 시점의 stale 상태이면
// merge 순서에 의해 pendingLocalState(NEW)를 contextLocalState(OLD)가 덮어쓰는 문제 발생
// sequence 핸들러는 G7Core.dispatch를 경유하지 않으므로 이 코드 경로에 영향 없음
let contextData = actionContext?.data ?? globalState;
if (pendingLocalState && actionContext?.data?._local) {
contextData = {
...actionContext.data,
_local: pendingLocalState,
};
logger.log('[dispatch] Updated context.data._local with pendingLocalState');
}
const context = {
navigate: router
? (path: string, options?: { replace?: boolean; state?: any }) =>
router.navigate(path, options)
: undefined,
// 액션 컨텍스트가 있으면 컴포넌트의 setState/state 사용, 없으면 전역 사용
setState: actionContext?.setState ?? setGlobalState,
state: contextState,
data: contextData,
// engine-v1.17.2: 비동기 콜백에서 dispatch 호출 시 target=local setState 처리 지원
// 컴포넌트 컨텍스트 없이 dispatch 호출된 경우 마커 설정
// handleSetState에서 이 마커를 확인하여 globalStateUpdater({ _local: ... }) 경로 사용
_isDispatchFallbackContext: !actionContext?.setState,
};
// engine-v1.41.0: debounce 옵션 처리
// ActionDispatcher의 debouncedCall을 사용하여 기존 타이머 인프라 활용
if (action.debounce) {
const debounceKey = action.debounceKey || `dispatch-${action.handler}`;
const delay = typeof action.debounce === 'number' ? action.debounce : action.debounce.delay;
actionDispatcher.debouncedCall(debounceKey, delay, () => {
// 디바운스 타이머 fire 시 최신 컨텍스트로 액션 실행
const { debounce: _d, debounceKey: _k, ...actionWithoutDebounce } = action;
actionDispatcher.dispatchAction(actionWithoutDebounce, context);
});
return { success: true, debounced: true };
}
try {
return await actionDispatcher.dispatchAction(action, context);
} catch (error) {
logger.error('G7Core.dispatch: 액션 실행 오류:', error);
return {
success: false,
error: error instanceof Error ? error : new Error(String(error)),
};
}
};
logger.log('전역 객체 window.G7Core.dispatch에 노출됨');
}
/**
* dot notation 경로를 중첩 객체로 변환합니다.
*
* @example
* convertDotNotationToObject({ "filter.orderStatus": ["paid"] })
* // Returns: { filter: { orderStatus: ["paid"] } }
*
* @param updates 변환할 객체
* @returns 중첩 객체로 변환된 결과
*/
function convertDotNotationToObject(updates: Record<string, any>): Record<string, any> {
const result: Record<string, any> = {};
for (const [key, value] of Object.entries(updates)) {
if (key.includes('.')) {
// dot notation 경로를 배열로 분리
const keys = key.split('.');
let current = result;
// 중첩 객체 생성
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i];
if (!(k in current)) {
current[k] = {};
} else if (typeof current[k] !== 'object' || current[k] === null) {
// 기존 값이 객체가 아니면 객체로 교체
current[k] = {};
}
current = current[k];
}
// 마지막 키에 값 할당
current[keys[keys.length - 1]] = value;
} else {
// dot notation이 아니면 그대로 복사
result[key] = value;
}
}
return result;
}
/**
* 중첩 객체에서 리프(leaf) 경로 전부를 dot notation 문자열 배열로 추출합니다.
*
* engine-v1.43.0+ 자동바인딩 경로 자동 승격 감지에 사용.
* `__g7AutoBindingPaths` 레지스트리에 fullPath 형식(`form.title`)으로 키가 등록되므로,
* setLocal의 `converted` 중첩 객체에서도 동일한 형식의 리프 경로를 추출해 교집합을 검사한다.
*
* 배열은 리프로 취급 (자동바인딩은 배열 자체 값에 매핑되므로).
*
* @example
* flattenLeafPaths({ form: { title: "X", content: "Y" } })
* // Returns: ["form.title", "form.content"]
*
* flattenLeafPaths({ hasChanges: true, form: { tags: ["a", "b"] } })
* // Returns: ["hasChanges", "form.tags"]
*
* @param obj 중첩 객체
* @param prefix 재귀용 prefix (내부)
* @returns 리프 경로 문자열 배열
*/
function flattenLeafPaths(obj: Record<string, any>, prefix = ''): string[] {
const paths: string[] = [];
if (!obj || typeof obj !== 'object') return paths;
for (const key of Object.keys(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
const value = obj[key];
if (value && typeof value === 'object' && !Array.isArray(value)) {
paths.push(...flattenLeafPaths(value, fullKey));
} else {
paths.push(fullKey);
}
}
return paths;
}
/**
* 객체의 모든 키가 숫자(배열 인덱스)인지 확인합니다.
*
* @param obj 확인할 객체
* @returns 모든 키가 숫자이면 true
*/
function hasOnlyNumericKeys(obj: Record<string, any>): boolean {
const keys = Object.keys(obj);
return keys.length > 0 && keys.every((k) => /^\d+$/.test(k));
}
/**
* 두 객체를 깊은 병합(deep merge)합니다.
*
* 특수 케이스: target이 배열이고 source가 숫자 키만 가진 객체인 경우,
* source의 각 키를 배열 인덱스로 해석하여 해당 위치의 요소를 병합합니다.
* 예: target = [{a:1}, {b:2}], source = {"0": {c:3}} → [{a:1, c:3}, {b:2}]
*
* @param target 병합 대상 객체
* @param source 병합할 소스 객체
* @returns 병합된 결과 객체
*/
// addMissingLeafKeys 는 engine-v1.63.3 에서 helpers/StateMerge 로 이동했다.
// ActionDispatcher 의 handleSetState COMPONENT path 가 같은 보충 규칙을 써야 하는데
// G7CoreGlobals → ActionDispatcher import 가 이미 있어 역방향 값 import 가 순환이 되기 때문이다.
// 동작은 원문 그대로다 (사례 13 회귀 테스트가 잠금 역할).
function deepMerge(target: Record<string, any>, source: Record<string, any>): Record<string, any> {
// 특수 케이스: target이 배열이고 source가 숫자 키만 가진 객체인 경우
// → 배열 요소를 인덱스별로 병합
if (Array.isArray(target) && !Array.isArray(source) && hasOnlyNumericKeys(source)) {
const numericKeys = Object.keys(source).map((k) => parseInt(k, 10));
const maxKey = numericKeys.length > 0 ? Math.max(...numericKeys) : 0;
// Sparse array 방지: source의 최대 키가 target 배열 범위를 크게 초과하면
// 배열 인덱스가 아닌 ID/키 매핑(예: product_option_id → coupon)으로 간주
// → 배열 병합 대신 일반 객체 병합으로 처리 (sparse array 생성 방지)
if (maxKey >= target.length + numericKeys.length + 10) {
// Fall through to normal object merge below
} else {
const result = [...target];
for (const [key, value] of Object.entries(source)) {
const index = parseInt(key, 10);
if (index >= 0 && index < result.length) {
// 인덱스가 범위 내에 있으면 해당 요소와 병합
if (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
result[index] !== null &&
typeof result[index] === 'object'
) {
result[index] = deepMerge(result[index], value);
} else {
result[index] = value;
}
} else if (index >= result.length) {
// 인덱스가 범위를 초과하면 배열 확장
result[index] = value;
}
}
return result as any;
}
}
const result = { ...target };
for (const [key, value] of Object.entries(source)) {
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
// 중첩 객체인 경우 재귀적으로 병합
if (result[key] !== null && typeof result[key] === 'object') {
// target[key]가 배열이든 객체든 deepMerge가 처리
result[key] = deepMerge(result[key], value);
} else {
result[key] = { ...value };
}
} else {
// 원시 값 또는 배열인 경우 그대로 할당
result[key] = value;
}
}
return result;
}
/**
* 전역 상태 관리 API 초기화
*/
function initStateAPI(G7Core: any): void {
G7Core.state = {
/**
* 현재 전역 상태를 반환합니다.
*/
get: () => {
const templateApp = (window as any).__templateApp;
return templateApp?.getGlobalState?.() || {};
},
/**
* 전역 상태를 업데이트합니다.
*
* dot notation을 지원합니다.
* 예: `{ "filter.orderStatus": ["paid"] }` → `{ filter: { orderStatus: ["paid"] } }`
*
* @param updates 업데이트할 상태 객체
* @param options 병합 옵션 { merge?: 'replace' | 'shallow' | 'deep' }
*/
set: (updates: Record<string, any>, options?: { merge?: 'replace' | 'shallow' | 'deep'; render?: boolean }) => {
const templateApp = (window as any).__templateApp;
if (templateApp?.setGlobalState && templateApp?.getGlobalState) {
const mergeMode = options?.merge || 'deep';
// dot notation을 중첩 객체로 변환
const converted = convertDotNotationToObject(updates);
let finalState: Record<string, any>;
if (mergeMode === 'replace') {
finalState = converted;
} else if (mergeMode === 'shallow') {
const currentState = templateApp.getGlobalState();
finalState = { ...currentState, ...converted };
} else {
// deep (기본값)
const currentState = templateApp.getGlobalState();
finalState = deepMerge(currentState, converted);
}
// engine-v1.42.0: render 옵션 전파
templateApp.setGlobalState(finalState, { render: options?.render });
} else {
logger.warn('G7Core.state.set: TemplateApp이 초기화되지 않았습니다.');
}
},
/**
* 컴포넌트 로컬 상태(_local)를 업데이트합니다.
*
* 커스텀 핸들러에서 컴포넌트의 로컬 상태를 직접 업데이트할 때 사용합니다.
* 이 메서드는 ActionDispatcher의 액션 실행 중에만 유효합니다.
*
* - 액션 컨텍스트가 있는 경우: 컴포넌트의 dynamicState를 직접 업데이트하여 즉시 UI에 반영
* - 액션 컨텍스트가 없는 경우: 전역 _local을 업데이트 (fallback)
*
* dot notation을 지원합니다.
* 예: `{ "filter.orderStatus": ["paid"] }` → `{ filter: { orderStatus: ["paid"] } }`
*
* @param updates 업데이트할 로컬 상태 객체
*
* @example
* ```ts
* // 커스텀 핸들러에서 사용
* export function myHandler(action: any, context: any): void {
* const G7Core = (window as any).G7Core;
*
* // 현재 로컬 상태 가져오기
* const currentLocal = G7Core.state.getLocal();
*
* // 로컬 상태 업데이트 (현재 컴포넌트의 상태)
* G7Core.state.setLocal({
* selectedItems: [...currentLocal.selectedItems, newItem],
* isLoading: false,
* });
*
* // dot notation 지원
* G7Core.state.setLocal({
* "filter.orderStatus": ["paid", "shipped"],
* });
*
* // scope 옵션으로 부모/루트 레이아웃에 상태 저장 (모달 등에서 유용)
* G7Core.state.setLocal({
* "form.fields": updatedFields,
* }, { scope: 'parent' });
* }
* ```
*/
setLocal: (updates: Record<string, any>, options?: { scope?: 'current' | 'parent' | 'root'; merge?: 'replace' | 'shallow' | 'deep'; debounce?: number; debounceKey?: string; render?: boolean; selfManaged?: boolean }) => {
const templateApp = (window as any).__templateApp;
// [engine-v1.43.0+] 이중 저장소 동기화 보호 — 자동바인딩 경로 자동 승격
//
// 배경: 엔진은 폼 데이터를 React localDynamicState(저장소 A)와 globalState._local(저장소 B)에
// 이중 저장한다. render:false 호출이 저장소 A에 자동바인딩된 경로를 건드리면 A가 갱신되지 않아
// Input이 stale 값을 표시한다. DynamicRenderer의 propsWithAutoBinding 주변 useEffect가 활성
// 자동바인딩 경로를 __g7AutoBindingPaths: Map<string, number>에 ref count로 등록한다.
//
// 규칙: setLocal({render:false})가 레지스트리에 등록된 경로를 업데이트하면 render:true로
// 강제 승격하여 A↔B 정합성 보장. 예외는 selfManaged:true를 명시한 호출 — CKEditor5 등
// 자체 DOM 관리 플러그인이 의도적으로 render:false를 유지하려 할 때 사용. 누락 시 엔진이
// 자동 승격하여 안전 확보 (safe-by-default).
//
// 자세한 설계 배경: DynamicRenderer.tsx의 performStateUpdate 상단 주석 참조.
if (options?.render === false && !options?.selfManaged) {
const registry = (window as any).__g7AutoBindingPaths as Map<string, number> | undefined;
if (registry && registry.size > 0) {
const leafPaths = flattenLeafPaths(convertDotNotationToObject(updates));
if (leafPaths.some((path) => registry.has(path))) {
logger.log(
'[setLocal] render:false + 자동바인딩 경로 겹침 감지 → render:true 자동 승격 (engine-v1.43.0)'
);
options = { ...options, render: true };
}
}
}
// engine-v1.41.0: debounce 옵션 처리
// ActionDispatcher의 debouncedCall을 사용하여 기존 타이머 인프라 활용
// 컴포넌트 언마운트 시 자동 정리 + flushPendingDebounceTimers 연동
if (options?.debounce) {
const actionDispatcher = templateApp?.getActionDispatcher?.();
if (actionDispatcher) {
const key = options.debounceKey || `setLocal-${Object.keys(updates).join(',')}`;
// 디바운스 타이머 fire 시 원래 setLocal 로직을 debounce 없이 재호출
const { debounce: _d, debounceKey: _k, ...restOptions } = options;
actionDispatcher.debouncedCall(key, options.debounce, () => {
G7Core.state.setLocal(updates, restOptions);
});
return;
}
}
const scope = options?.scope ?? 'current';
const mergeMode = options?.merge || 'deep';
// ActionDispatcher에서 설정한 현재 컴포넌트 컨텍스트 확인
const actionContext = (window as any).__g7ActionContext;
// dot notation을 중첩 객체로 변환
const converted = convertDotNotationToObject(updates);
// scope가 'parent' 또는 'root'인 경우 레이아웃 컨텍스트 스택에서 타겟 컨텍스트 사용
if (scope === 'parent' || scope === 'root') {
const contextStack: Array<{ state: Record<string, any>; setState: (updates: any) => void }> =
(window as any).__g7LayoutContextStack || [];
if (contextStack.length > 0) {
// parent: 스택의 마지막 (바로 이전 컨텍스트)
// root: 스택의 첫 번째 (최상위 컨텍스트)
const targetContext = scope === 'parent'
? contextStack[contextStack.length - 1]
: contextStack[0];
if (targetContext?.setState) {
// 함수형 업데이트를 사용하여 항상 최신 상태와 병합
// React setState는 비동기이므로 스냅샷 대신 함수형 업데이트 필수
//
// scope:'parent'|'root' 은 `_local` 정본이 아니라 레이아웃 컨텍스트 스택의
// 부모/루트 슬롯을 대상으로 한다. 여기서 저장소 B 를 함께 쓰면 모달의 쓰기가
// 페이지 _local 을 오염시킨다(사례 29).
// audit:allow local-store-write-must-mirror 부모/루트 슬롯 전용 (위 사유)
targetContext.setState((currentLocal: Record<string, any>) => {
if (mergeMode === 'replace') return converted;
if (mergeMode === 'shallow') return { ...(currentLocal || {}), ...converted };
return deepMerge(currentLocal || {}, converted);
});
logger.log(`[setLocal] scope=${scope}: 타겟 컨텍스트에 상태 업데이트 (mergeMode=${mergeMode})`, converted);
return;
}
}
// 스택에 컨텍스트가 없으면 경고 후 current로 폴백
logger.warn(`[setLocal] scope=${scope}: 레이아웃 컨텍스트 스택이 비어있습니다. current로 폴백합니다.`);
}
// scope: 'current' (기본값)
// engine-v1.17.7: actionContext 유무와 관계없이 항상 globalLocal을 업데이트
// - actionContext.setState()는 클릭된 컴포넌트(ChipCheckbox)의 상태만 업데이트
// - ChipCheckbox의 상태에는 form.label_assignments가 없으므로 잘못된 대상
// - templateApp.setGlobalState({ _local: ... })가 실제 _local 상태를 관리
// - _localInit에서도 동일한 메커니즘 사용 (setGlobalState({ _local: ... }))
const pendingState = (window as any).__g7PendingLocalState;
const globalLocal = templateApp?.getGlobalState?.()?._local || {};
// engine-v1.22.1: actionContext.state(=dynamicState)도 병합하여 전체 _local 반영
// ActionDispatcher setState(target: "_local")로 설정된 값은 localDynamicState에만 존재하고
// globalLocal(=templateApp.getGlobalState()._local)에는 없음.
// setLocal 후 openModal 시 __g7PendingLocalState가 $parent._local 스냅샷으로 사용되므로,
// dynamicState의 값(예: selectedProducts)이 누락되는 문제 발생.
//
// engine-v1.24.7: 모달 내부에서 호출 시 actionContext.state 병합 제외
//
// engine-v1.41.0: deepMerge(globalLocal, dynamicLocal)은 stale dynamicLocal의 빈 배열이
// globalLocal의 정상 배열을 교체하는 오염 경로를 생성함 (CKEditor + SPA 네비게이션 시 재현).
// dynamicLocal은 actionContext.state(=컴포넌트 렌더 시점 스냅샷)이므로
// init_actions 기본값(category_ids:[], options:[])을 포함할 수 있음.
// globalLocal은 최신 committed 상태이므로 dynamicLocal과 충돌 시 globalLocal이 정확함.
// → globalLocal을 기반으로 dynamicLocal에서 globalLocal에 없는 키만 추가하는
// addMissingLeafKeys 전략으로 변경. v1.22.1 목적(setState 전용 키 보존)은 유지하면서
// stale 배열 덮어쓰기 방지.
const layoutContextStack = (window as any).__g7LayoutContextStack || [];
const isInsideModal = layoutContextStack.length > 0;
const dynamicLocal = (!isInsideModal && actionContext?.state) ? actionContext.state : undefined;
const baseLocal = dynamicLocal
? addMissingLeafKeys(globalLocal, dynamicLocal)
: globalLocal;
const currentSnapshot = pendingState || baseLocal;
let mergedPending: Record<string, any>;
if (mergeMode === 'replace') {
mergedPending = converted;
} else if (mergeMode === 'shallow') {
mergedPending = { ...currentSnapshot, ...converted };
} else {
mergedPending = deepMerge(currentSnapshot, converted);
}
// 1. 전역 _local 상태 업데이트 (핵심!)
// engine-v1.42.0: render 옵션 전파 — render: false이면 값만 저장, React 렌더링 건너뜀
if (templateApp?.setGlobalState) {
templateApp.setGlobalState({ _local: mergedPending }, { render: options?.render });
logger.log(`[setLocal] globalLocal updated via setGlobalState (mergeMode=${mergeMode}, render=${options?.render ?? true}):`, converted);
}
// 2. pendingState도 업데이트 (같은 이벤트 루프 내 후속 getLocal() 호출용)
(window as any).__g7PendingLocalState = mergedPending;
logger.log('[setLocal] __g7PendingLocalState updated:', mergedPending);
// 2-1. sequence 내 커스텀 핸들러 동기화용 별도 변수 설정
// __g7PendingLocalState는 setGlobalState → updateTemplateData → root.render() →
// useLayoutEffect에서 null로 클리어될 수 있음 (await 해제 시 마이크로태스크 플러시)
// __g7SequenceLocalSync는 handleSequence에서만 읽고 초기화하므로 안전
(window as any).__g7SequenceLocalSync = mergedPending;
// 2-2. engine-v1.21.2: getLocal() await fallback용 스냅샷
// __g7PendingLocalState가 useLayoutEffect에서 클리어된 후에도 getLocal()이
// 최신 setLocal 값을 반환할 수 있도록 별도 스냅샷 유지.
// handleLocalSetState에서는 참조하지 않으므로 stale 오염 없음.
// dataContext._local이 갱신되면 클리어됨 (DynamicRenderer useLayoutEffect).
(window as any).__g7LastSetLocalSnapshot = mergedPending;
// 3. forcedLocalFields 업데이트 (dataKey 자동 바인딩 컴포넌트 충돌 방지)
if (mergeMode === 'replace') {
// replace 모드: 기존 forcedLocalFields 초기화 후 새 값으로 교체
(window as any).__g7ForcedLocalFields = converted;
} else {
const existingForced = (window as any).__g7ForcedLocalFields || {};
(window as any).__g7ForcedLocalFields = deepMerge(existingForced, converted);
}
logger.log('[setLocal] __g7ForcedLocalFields updated:', converted);
// engine-v1.17.8: setLocal이 업데이트한 키를 기록하여 ROOT의 localDynamicState에서 제거
// 폼 자동 바인딩이 localDynamicState에 기록한 stale 배열이 dataContext._local을 덮어쓰는 문제 방지
// setState(target: "local")가 아닌 setLocal만 기록 (setState는 localDynamicState를 직접 업데이트)
if (mergeMode === 'replace') {
(window as any).__g7SetLocalOverrideKeys = converted;
} else {
const existingKeys = (window as any).__g7SetLocalOverrideKeys || {};
(window as any).__g7SetLocalOverrideKeys = deepMerge(existingKeys, converted);
}
logger.log('[setLocal] __g7SetLocalOverrideKeys updated:', converted);
// 4. actionContext가 있으면 컴포넌트 로컬 상태도 업데이트 (리액티비티용)
if (actionContext?.setState) {
actionContext.setState((currentLocal: Record<string, any>) => {
if (mergeMode === 'replace') return converted;
if (mergeMode === 'shallow') return { ...(currentLocal || {}), ...converted };
return deepMerge(currentLocal || {}, converted);
});
}
// templateApp이 없는 경우 경고
if (!templateApp?.setGlobalState) {
logger.warn('G7Core.state.setLocal: TemplateApp이 없습니다.');
}
},
/**
* 현재 컴포넌트 로컬 상태를 반환합니다.
*
* 커스텀 핸들러에서 현재 컴포넌트의 로컬 상태를 조회할 때 사용합니다.
*
* @returns 현재 로컬 상태 객체
*
* @example
* ```ts
* // 현재 컴포넌트 로컬 상태
* const currentLocal = G7Core.state.getLocal();
* console.log(currentLocal.selectedItems);
* ```
*/
getLocal: (): Record<string, any> => {
const templateApp = (window as any).__templateApp;
// engine-v1.17.6: globalLocal + pendingState 2단계 병합
//
// 주의: componentState(actionContext.state)를 사용하면 안 됨!
// - actionContext는 클릭된 컴포넌트(예: ChipCheckbox)의 컨텍스트
// - ChipCheckbox의 state에는 form.label_assignments가 없거나 빈 배열
// - 이것이 globalLocal의 정확한 API 데이터를 덮어씀
//
// globalLocal은 _localInit에서 API 데이터가 적용된 후 동기화됨
// (DynamicRenderer의 _localInit effect가 setGlobalState({ _local: ... }) 호출)
//
// 참고: __g7CommittedLocalState(React 커밋 후 extendedDataContext._local)를
// 폴백으로 사용하면 다중 root-level DynamicRenderer(메인 + 모달) 환경에서
// 마지막으로 렌더된 모달의 _local이 메인 페이지 상태를 덮어쓰는 문제 발생.
// 비동기 콜백에서 최신 _local이 필요한 경우, 호출자가 직접
// handlerContext.state(버튼 클릭 시점의 componentContext.state)를 사용해야 함.
//
// ──────────────────────────────────────────────────────────────────
// [모달 scope 제한사항] ( 분석, 2026-03-25)
//
// 현재 getLocal()은 항상 페이지의 globalLocal을 반환한다.
// modals 섹션의 isolated scope 모달 내부 커스텀 핸들러에서 호출해도
// 모달의 _local이 아닌 페이지의 _local을 읽는다.
//
// 반면 layout JSON의 `setState target: "local"`은 ActionDispatcher가
// context.setState를 사용하여 모달 DynamicRenderer의 state를 직접 업데이트한다.
//
// 이 비대칭으로 인해:
// - 모달 onMount에서 setState로 모달 scope에 데이터를 복사하면,
// 이후 핸들러의 getLocal()은 페이지 scope를 읽어 scope 단절 발생
// - setLocal()도 globalLocal에 쓰므로 모달 UI에 반영되지 않음
// (단, actionContext.setState 호출은 있어 컴포넌트 레벨 업데이트는 가능)
//
// 현재 우회책: 모달에서 onMount setState를 사용하지 않고,
// 모달 열기 전 핸들러에서 setLocal()로 페이지 _local에 초기화 후 modal.open().
// 이렇게 하면 모달이 페이지 _local을 그대로 상속하여 getLocal/setLocal과 일관.
// (사용자 취소 모달 _modal_cancel.json, 관리자 취소 모달 _modal_cancel_order.json)
//
// [향후 리팩토링 방향]
// getLocal()/setLocal()을 모달-인식(modal-aware)으로 개선:
// 1. __g7ActionContext에 모달 루트 DynamicRenderer의 state/setState 추가
// (현재는 클릭된 리프 컴포넌트의 state만 있어 부분적임)
// 2. getLocal()에서 __g7ActionContext의 모달 루트 state를 우선 반환
// 3. setLocal()에서 모달 루트 setState를 우선 호출
//
// 주의 사항 (회귀 위험):
// - __g7PendingLocalState가 scope 무인식 전역 싱글톤이라
// setLocal→getLocal→dispatch 체인에서 scope mismatch 발생 가능
// - troubleshooting 사례 5,7(pendingState), 사례 3(SPA navigate),
// global 사례 4(sequence→refetch) 등 기존 수정과 충돌 가능
// - actionContext.state가 리프 컴포넌트의 부분 상태인 문제 해결 필요
// (모달 루트 DynamicRenderer의 전체 _local state 접근 경로 확보)
// ──────────────────────────────────────────────────────────────────
const globalState = templateApp?.getGlobalState?.() || {};
const globalLocal = globalState._local || {};
// pendingState: setLocal() 호출 후 아직 React에 커밋되지 않은 업데이트
const pendingState = (window as any).__g7PendingLocalState;
if (pendingState) {
return deepMerge(globalLocal, pendingState);
}
// engine-v1.21.2: __g7LastSetLocalSnapshot fallback
// __g7PendingLocalState는 useLayoutEffect에서 매 렌더마다 null로 클리어됨.
// 이는 handleLocalSetState에서의 stale pendingState 오염을 방지하기 위해 필요하지만,
// await 경계에서 React 18 마이크로태스크 배칭이 렌더를 플러시하면
// pendingState가 사라져 getLocal()이 stale한 globalLocal만 반환하는 문제 발생.
//
// __g7LastSetLocalSnapshot은 setLocal()에서 함께 설정되지만 useLayoutEffect에서
// 클리어되지 않으므로, await 이후에도 최신 setLocal 값을 유지함.
// handleLocalSetState에서는 참조하지 않으므로 stale 오염 없음.
// globalLocal과 동일 참조인 경우 이미 커밋된 것이므로 스킵.
const lastSnapshot = (window as any).__g7LastSetLocalSnapshot;
if (lastSnapshot && lastSnapshot !== globalLocal) {
return deepMerge(globalLocal, lastSnapshot);
}
return globalLocal;
},
/**
* 현재 상태를 기반으로 전역 상태를 업데이트합니다.
* React의 setState 함수형 업데이트와 유사한 패턴입니다.
* @param updater 현재 상태를 받아 새 상태를 반환하는 함수
*/
update: (updater: (prevState: Record<string, any>) => Record<string, any>) => {
const templateApp = (window as any).__templateApp;
if (templateApp?.getGlobalState && templateApp?.setGlobalState) {
const currentState = templateApp.getGlobalState();
const newUpdates = updater(currentState);
templateApp.setGlobalState(newUpdates);
} else {
logger.warn('G7Core.state.update: TemplateApp이 초기화되지 않았습니다.');
}
},
/**
* 전역 상태 변경을 구독합니다.
* @param listener 상태 변경 시 호출될 콜백 함수
* @returns 구독 해제 함수
*/
subscribe: (listener: (state: Record<string, any>) => void) => {
const templateApp = (window as any).__templateApp;
if (templateApp?.onGlobalStateChange) {
return templateApp.onGlobalStateChange(listener);
}
logger.warn('G7Core.state.subscribe: TemplateApp이 초기화되지 않았습니다.');
return () => {}; // no-op unsubscribe
},
/**
* 데이터 소스 값을 가져옵니다.
* @param dataSourceId 데이터 소스 ID (예: 'menus', 'users')
* @returns 데이터 소스 값 또는 undefined
*/
getDataSource: (dataSourceId: string) => {
const templateApp = (window as any).__templateApp;
return templateApp?.getDataSource?.(dataSourceId);
},
/**
* 격리된 상태(isolated state)를 scopeId로 조회합니다.
*
* 커스텀 핸들러에서 특정 격리 스코프의 상태를 조회할 때 사용합니다.
* 액션 컨텍스트가 있는 경우 해당 컨텍스트의 isolatedContext를 우선 사용합니다.
*
* @param scopeId 격리 스코프 ID (선택, 미지정 시 현재 액션 컨텍스트의 격리 상태 반환)
* @returns 격리된 상태 객체 또는 null
*
* @example
* ```ts
* // 현재 액션 컨텍스트의 격리 상태 조회
* const isolated = G7Core.state.getIsolated();
* console.log(isolated?.step);
*
* // 특정 scopeId로 조회 (레지스트리에서)
* const categoryState = G7Core.state.getIsolated('category-selector');
* console.log(categoryState?.selectedItems);
* ```
*/
getIsolated: (scopeId?: string): Record<string, any> | null => {
// 1. scopeId가 지정된 경우 레지스트리에서 조회
if (scopeId) {
const registry = (window as any).__g7IsolatedStates;
const scope = registry?.[scopeId];
return scope?.state ?? null;
}
// 2. scopeId가 없으면 현재 액션 컨텍스트의 isolatedContext 사용
const actionContext = (window as any).__g7ActionContext;
return actionContext?.isolatedContext?.state ?? null;
},
/**
* 격리된 상태(isolated state)를 업데이트합니다.
*
* 커스텀 핸들러에서 격리된 상태를 직접 업데이트할 때 사용합니다.
* scopeId가 지정된 경우 해당 스코프를, 미지정 시 현재 액션 컨텍스트의 격리 상태를 업데이트합니다.
*
* dot notation을 지원합니다.
* 예: `{ "form.email": "test@example.com" }` → `{ form: { email: "test@example.com" } }`
*
* @param scopeIdOrUpdates scopeId (문자열) 또는 업데이트 객체
* @param maybeUpdates scopeId가 지정된 경우 업데이트 객체
*
* @example
* ```ts
* // 현재 액션 컨텍스트의 격리 상태 업데이트
* G7Core.state.setIsolated({ step: 2, selectedItem: item });
*
* // 특정 scopeId로 업데이트
* G7Core.state.setIsolated('category-selector', { step: 2 });
*
* // dot notation 지원
* G7Core.state.setIsolated({ "form.email": "test@example.com" });
* ```
*/
setIsolated: (
scopeIdOrUpdates: string | Record<string, any>,
maybeUpdates?: Record<string, any>,
options?: { merge?: 'replace' | 'shallow' | 'deep' }
): void => {
let scopeId: string | undefined;
let updates: Record<string, any>;
let mergeOpts: { merge?: 'replace' | 'shallow' | 'deep' } | undefined;
// 오버로드 처리: (updates) 또는 (scopeId, updates) 또는 (updates, options)
if (typeof scopeIdOrUpdates === 'string') {
scopeId = scopeIdOrUpdates;
updates = maybeUpdates || {};
mergeOpts = options;
} else {
updates = scopeIdOrUpdates;
// 두 번째 인자가 options인지 확인
mergeOpts = maybeUpdates && typeof maybeUpdates === 'object' && ('merge' in maybeUpdates) ? maybeUpdates as any : options;
}
const mergeMode: 'replace' | 'shallow' | 'deep' = mergeOpts?.merge || 'deep';
// dot notation을 중첩 객체로 변환
const converted = convertDotNotationToObject(updates);
// 1. scopeId가 지정된 경우 레지스트리에서 조회하여 업데이트
if (scopeId) {
const registry = (window as any).__g7IsolatedStates;
const scope = registry?.[scopeId];
if (scope?.mergeState) {
scope.mergeState(converted, mergeMode);
} else {
logger.warn(`G7Core.state.setIsolated: scopeId '${scopeId}'를 찾을 수 없습니다.`);
}
return;
}
// 2. scopeId가 없으면 현재 액션 컨텍스트의 isolatedContext 사용
const actionContext = (window as any).__g7ActionContext;
if (actionContext?.isolatedContext?.mergeState) {
actionContext.isolatedContext.mergeState(converted, mergeMode);
} else {
logger.warn('G7Core.state.setIsolated: 액션 컨텍스트에 isolatedContext가 없습니다.');
}
},
/**
* 부모 레이아웃의 컨텍스트를 반환합니다.
*
* 모달 등 자식 레이아웃에서 부모 레이아웃의 상태에 접근할 때 사용합니다.
* `__g7LayoutContextStack`에서 부모 컨텍스트를 가져옵니다.
*
* @returns 부모 컨텍스트 객체 또는 null
*
* @example
* ```ts
* // 커스텀 핸들러에서 부모 상태 조회
* const parentContext = G7Core.state.getParent();
* if (parentContext) {
* const parentLocal = parentContext._local;
* const parentGlobal = parentContext._global;
* console.log('부모 로컬 상태:', parentLocal);
* }
* ```
*
* @since engine-v1.16.0
*/
getParent: (): { _local: Record<string, any>; _global: Record<string, any>; setState: (updates: any) => void } | null => {
const contextStack: Array<{
state: Record<string, any>;
setState: (updates: any) => void;
dataContext?: Record<string, any>;
}> = (window as any).__g7LayoutContextStack || [];
if (contextStack.length === 0) {
logger.log('[getParent] 레이아웃 컨텍스트 스택이 비어있습니다.');
return null;
}
// 스택의 마지막 항목이 부모 컨텍스트
const parentEntry = contextStack[contextStack.length - 1];
if (!parentEntry) {
return null;
}
// dataContext가 있으면 $parent 바인딩에서 저장한 확장 컨텍스트 사용
const parentData = parentEntry.dataContext || {};
return {
_local: parentEntry.state || parentData._local || {},
_global: parentData._global || G7Core.state.get() || {},
setState: parentEntry.setState,
};
},
/**
* 부모 레이아웃의 로컬 상태(_local)를 업데이트합니다.
*
* 모달 등 자식 레이아웃에서 부모 레이아웃의 로컬 상태를 직접 수정할 때 사용합니다.
*
* dot notation을 지원합니다.
* 예: `{ "form.name": "value" }` → `{ form: { name: "value" } }`
*
* @param pathOrUpdates 경로 문자열 또는 업데이트 객체
* @param maybeValue 경로가 지정된 경우 설정할 값
*
* @example
* ```ts
* // 객체로 업데이트
* G7Core.state.setParentLocal({
* 'form.label_assignments': updatedAssignments,
* hasChanges: true,
* });
*
* // 경로와 값으로 업데이트
* G7Core.state.setParentLocal('form.name', 'newValue');
* ```
*
* @since engine-v1.16.0
*/
setParentLocal: (pathOrUpdates: string | Record<string, any>, maybeValue?: any, options?: { merge?: 'replace' | 'shallow' | 'deep' }): void => {
const contextStack: Array<{
state: Record<string, any>;
setState: (updates: any) => void;
dataContext?: Record<string, any>;
}> = (window as any).__g7LayoutContextStack || [];
if (contextStack.length === 0) {
logger.warn('[setParentLocal] 레이아웃 컨텍스트 스택이 비어있습니다.');
return;
}
const parentEntry = contextStack[contextStack.length - 1];
if (!parentEntry?.setState) {
logger.warn('[setParentLocal] 부모 컨텍스트에 setState가 없습니다.');
return;
}
let updates: Record<string, any>;
let mergeOpts: { merge?: 'replace' | 'shallow' | 'deep' } | undefined;
if (typeof pathOrUpdates === 'string') {
// 경로와 값으로 호출된 경우
updates = { [pathOrUpdates]: maybeValue };
mergeOpts = options;
} else {
updates = pathOrUpdates;
// 객체로 호출 시 두 번째 인자가 options일 수 있음
mergeOpts = maybeValue && typeof maybeValue === 'object' && ('merge' in maybeValue) ? maybeValue : options;
}
const mergeMode = mergeOpts?.merge || 'deep';
// dot notation을 중첩 객체로 변환
const converted = convertDotNotationToObject(updates);
// parentEntry.state가 전체 데이터 컨텍스트(_local 포함)인지 확인
// _local이 있으면 그 안의 상태를 업데이트, 없으면 state 자체가 로컬 상태
const hasNestedLocal = parentEntry.state?._local !== undefined;
const currentLocal = hasNestedLocal ? parentEntry.state._local : (parentEntry.state || {});
let merged: Record<string, any>;
if (mergeMode === 'replace') {
merged = converted;
} else if (mergeMode === 'shallow') {
merged = { ...currentLocal, ...converted };
} else {
merged = deepMerge(currentLocal, converted);
}
// CRITICAL: __g7PendingLocalState를 setState 호출 전에 설정해야 함!
// React가 setState 배치를 처리할 때 handleLocalSetState 콜백이 실행되는데,
// 이 콜백에서 __g7PendingLocalState를 읽어 최신 상태를 effectivePrev로 사용함.
// 따라서 setState 호출 전에 설정해야 콜백이 최신 값을 읽을 수 있음.
(window as any).__g7PendingLocalState = merged;
// engine-v1.17.12: 전역 _local 상태도 업데이트 (setLocal과 동일)
// setParentLocal은 parentEntry.setState()로 React 상태를 업데이트하지만,
// getLocal()은 templateApp.getGlobalState()._local에서 읽음.
// __g7PendingLocalState가 렌더 후 클리어되면 getLocal()이 stale 데이터를 반환하므로,
// globalState._local도 함께 업데이트해야 후속 getLocal() 호출이 최신 값을 반환함.
const templateApp = (window as any).__templateApp;
if (templateApp?.setGlobalState) {
// 저장소 B 쓰기의 base 는 live B 다 (engine-v1.63.3 / 공개 이슈 #130 과 같은 정책).
//
// `merged` 는 부모 컨텍스트의 **저장소 A**(`parentEntry.state._local`)를 base 로 만든 값이다.
// `setGlobalState` 는 `_local` 을 얕게 병합하므로 그것을 그대로 쓰면 B 가 통째 교체되고,
// A 가 아직 받지 못한 값(예: selfManaged 플러그인이 B 에만 쓴 편집기 본문)이 사라진다.
// 부모 컨텍스트가 페이지 루트일 때 그 A 스냅샷은 B 보다 뒤처져 있을 수 있다(사례 21).
//
// 그래서 B 에는 live B + 변경 키만 얹고, A 전용 키는 `addMissingLeafKeys` 로 보충한다.
// 저장소 A 경로(`parentEntry.setState`)와 pending 은 종전 그대로 둔다 — 그쪽 base 를
// 바꾸면 React 전용 배열이 B 초기값으로 덮이는 사례 22 위험이 생긴다.
//
// `merge: 'replace'` 는 의도적 리셋이므로 제외한다(사례 17).
const canonicalLocal = templateApp.getGlobalState?.()?._local;
const canUseCanonical = mergeMode !== 'replace'
&& !!canonicalLocal && typeof canonicalLocal === 'object' && !Array.isArray(canonicalLocal);
const canonicalMerged = canUseCanonical
? (mergeMode === 'shallow'
? { ...(canonicalLocal as Record<string, any>), ...converted }
: deepMerge(canonicalLocal as Record<string, any>, converted))
: undefined;
templateApp.setGlobalState({
_local: canonicalMerged ? addMissingLeafKeys(canonicalMerged, merged) : merged,
});
}
parentEntry.setState(merged);
// 스택의 state와 dataContext._local 모두 업데이트하여
// 다음 호출 시 currentLocal이 최신 값을 읽고,
// 모달에서 $parent._local 바인딩이 최신 값을 읽도록 함
if (hasNestedLocal) {
parentEntry.state._local = merged;
} else {
parentEntry.state = merged;
}
if (parentEntry.dataContext) {
parentEntry.dataContext._local = merged;
}
// __g7ForcedLocalFields 업데이트 (dataKey 자동 바인딩 컴포넌트 충돌 방지)
// setParentLocal로 업데이트된 필드가 Form 자동 바인딩에 의해 덮어쓰이지 않도록 함
if (mergeMode === 'replace') {
(window as any).__g7ForcedLocalFields = converted;
} else {
const existingForced = (window as any).__g7ForcedLocalFields || {};
(window as any).__g7ForcedLocalFields = deepMerge(existingForced, converted);
}
// ParentContextProvider를 통해 모달만 선택적으로 리렌더링
// 전체 앱 리렌더링 없이 모달만 업데이트됨
triggerModalParentUpdate();
},
/**
* 부모 레이아웃의 전역 상태(_global)를 업데이트합니다.
*
* 모달 등 자식 레이아웃에서 전역 상태를 직접 수정할 때 사용합니다.
* 참고: 전역 상태는 앱 전체에서 공유되므로, 부모/자식 구분 없이
* G7Core.state.set()과 동일하게 동작합니다. 이 메서드는 의미적 명확성을 위해 제공됩니다.
*
* dot notation을 지원합니다.
*
* @param pathOrUpdates 경로 문자열 또는 업데이트 객체
* @param maybeValue 경로가 지정된 경우 설정할 값
*
* @example
* ```ts
* // 객체로 업데이트
* G7Core.state.setParentGlobal({
* modalResult: { confirmed: true, data: selectedItems },
* });
*
* // 경로와 값으로 업데이트
* G7Core.state.setParentGlobal('modalResult.confirmed', true);
* ```
*
* @since engine-v1.16.0
*/
setParentGlobal: (pathOrUpdates: string | Record<string, any>, maybeValue?: any): void => {
let updates: Record<string, any>;
if (typeof pathOrUpdates === 'string') {
updates = { [pathOrUpdates]: maybeValue };
} else {
updates = pathOrUpdates;
}
// 전역 상태는 앱 전체에서 공유되므로 G7Core.state.set() 사용
G7Core.state.set(updates);
logger.log('[setParentGlobal] 전역 상태 업데이트:', updates);
},
};
// 격리 상태 레지스트리 초기화
if (!(window as any).__g7IsolatedStates) {
(window as any).__g7IsolatedStates = {};
}
// 별칭 메서드 추가 (하위 호환성)
G7Core.state.getGlobal = G7Core.state.get;
G7Core.state.setGlobal = G7Core.state.set;
logger.log('전역 객체 window.G7Core.state에 노출됨');
}
/**
* 데이터 소스 관리 API 초기화
*
* 데이터 소스의 조회, 설정, refetch 등을 관리합니다.
*/
function initDataSourceAPI(G7Core: any): void {
G7Core.dataSource = {
/**
* 데이터 소스 값을 가져옵니다.
* @param dataSourceId 데이터 소스 ID
* @returns 데이터 소스 값 또는 undefined
*/
get: (dataSourceId: string) => {
const templateApp = (window as any).__templateApp;
return templateApp?.getDataSource?.(dataSourceId);
},
/**
* 데이터 소스 값을 설정하고 UI를 리렌더링합니다.
*
* 서버 refetch 없이 클라이언트 측에서 데이터 소스를 직접 업데이트할 때 사용합니다.
*
* @param dataSourceId 데이터 소스 ID
* @param data 설정할 데이터
* @param options 옵션
* @param options.merge true면 기존 데이터와 병합 (기본값: false)
* @param options.sync true면 동기 업데이트 (기본값: false)
*
* @example
* // 데이터 전체 교체
* G7Core.dataSource.set('products', { data: updatedProducts, meta: {...} });
*
* // 기존 데이터와 병합
* G7Core.dataSource.set('products', { data: updatedProducts }, { merge: true });
*/
set: (
dataSourceId: string,
data: any,
options?: { merge?: boolean; sync?: boolean }
) => {
const templateApp = (window as any).__templateApp;
if (templateApp?.setDataSource) {
templateApp.setDataSource(dataSourceId, data, options);
} else {
logger.warn('G7Core.dataSource.set: TemplateApp이 초기화되지 않았습니다.');
}
},
/**
* 데이터 소스를 서버에서 다시 가져옵니다.
* @param dataSourceId 데이터 소스 ID
* @param options 옵션
* @returns refetch 결과 데이터
*/
refetch: async (dataSourceId: string, options?: { skipCache?: boolean; sync?: boolean }) => {
const templateApp = (window as any).__templateApp;
if (templateApp?.refetchDataSource) {
return templateApp.refetchDataSource(dataSourceId, options);
}
logger.warn('G7Core.dataSource.refetch: TemplateApp이 초기화되지 않았습니다.');
return undefined;
},
/**
* 데이터 소스 내 특정 배열 아이템만 업데이트합니다.
*
* 전체 데이터소스를 교체하지 않고 특정 아이템만 수정하여
* 불필요한 리렌더링을 방지합니다.
*
* @param dataSourceId 데이터 소스 ID
* @param itemPath 배열 경로 (예: "data.data", "data.data[0].options")
* @param itemId 업데이트할 아이템의 ID
* @param updates 업데이트할 필드들
* @param options 옵션
* @returns 성공 여부
*
* @example
* ```typescript
* // 상품 옵션 업데이트
* G7Core.dataSource.updateItem(
* 'products',
* 'data.data[0].options',
* 123, // optionId
* { selling_price: 15000, _modified: true }
* );
*
* // 사용자 정보 업데이트 (커스텀 ID 필드 사용)
* G7Core.dataSource.updateItem(
* 'users',
* 'data',
* 'user-456',
* { name: '홍길동' },
* { idField: 'uuid' }
* );
* ```
*/
updateItem: (
dataSourceId: string,
itemPath: string,
itemId: string | number,
updates: Record<string, any>,
options?: {
/** ID 필드명 (기본: "id") */
idField?: string;
/** 깊은 병합 여부 (기본: true) */
merge?: boolean;
/** 렌더링 스킵 여부 (기본: false) */
skipRender?: boolean;
}
): boolean => {
const templateApp = (window as any).__templateApp;
if (templateApp?.updateDataSourceItem) {
return templateApp.updateDataSourceItem(dataSourceId, itemPath, itemId, updates, options);
}
logger.warn('G7Core.dataSource.updateItem: TemplateApp이 초기화되지 않았습니다.');
return false;
},
/**
* 데이터 소스 내 특정 경로의 배열에 새 데이터를 병합합니다.
*
* 무한 스크롤 등에서 기존 데이터에 새 데이터를 추가할 때 사용합니다.
*
* @param dataSourceId 데이터 소스 ID
* @param dataPath 배열 경로 (예: "data", "data.items"). null이면 루트 배열
* @param newData 추가할 새 데이터 (배열)
* @param mode 병합 모드 - 'append': 끝에 추가, 'prepend': 앞에 추가 (기본: 'append')
* @returns 성공 여부
*
* @example
* ```typescript
* // 무한 스크롤: 기존 목록 뒤에 새 데이터 추가
* G7Core.dataSource.updateData('templates', 'data', newTemplates, 'append');
*
* // 새로고침: 새 데이터를 앞에 추가
* G7Core.dataSource.updateData('notifications', 'data', newNotifications, 'prepend');
* ```
*/
updateData: (
dataSourceId: string,
dataPath: string | null,
newData: any[],
mode: 'append' | 'prepend' = 'append'
): boolean => {
const templateApp = (window as any).__templateApp;
if (!templateApp?.getDataSource || !templateApp?.setDataSource) {
logger.warn('G7Core.dataSource.updateData: TemplateApp이 초기화되지 않았습니다.');
return false;
}
// 현재 데이터 소스 가져오기
const currentDataSource = templateApp.getDataSource(dataSourceId);
if (!currentDataSource) {
logger.warn(`G7Core.dataSource.updateData: 데이터 소스 '${dataSourceId}'를 찾을 수 없습니다.`);
return false;
}
// 경로에서 대상 배열 가져오기
let targetArray: any[];
let parentObj: any = currentDataSource;
let lastKey: string | null = null;
if (dataPath) {
const pathParts = dataPath.split('.');
lastKey = pathParts.pop()!;
for (const part of pathParts) {
if (parentObj && typeof parentObj === 'object' && part in parentObj) {
parentObj = parentObj[part];
} else {
logger.warn(`G7Core.dataSource.updateData: 경로 '${dataPath}'를 찾을 수 없습니다.`);
return false;
}
}
targetArray = parentObj[lastKey];
} else {
targetArray = currentDataSource;
}
if (!Array.isArray(targetArray)) {
logger.warn(`G7Core.dataSource.updateData: 대상 경로의 데이터가 배열이 아닙니다.`);
return false;
}
if (!Array.isArray(newData)) {
logger.warn(`G7Core.dataSource.updateData: newData가 배열이 아닙니다.`);
return false;
}
// 배열 병합
const mergedArray = mode === 'prepend'
? [...newData, ...targetArray]
: [...targetArray, ...newData];
// 업데이트된 데이터 설정
if (dataPath && lastKey) {
parentObj[lastKey] = mergedArray;
templateApp.setDataSource(dataSourceId, currentDataSource, { merge: false });
} else {
templateApp.setDataSource(dataSourceId, mergedArray, { merge: false });
}
logger.log(`G7Core.dataSource.updateData: '${dataSourceId}'에 ${newData.length}개 항목 ${mode === 'prepend' ? '앞에' : '뒤에'} 추가됨`);
return true;
},
};
logger.log('전역 객체 window.G7Core.dataSource에 노출됨');
}
/**
* 로케일 관리 API 초기화
*/
function initLocaleAPI(G7Core: any, deps: G7CoreDependencies): void {
G7Core.locale = {
/**
* 현재 로케일을 반환합니다.
*/
current: () => {
const templateApp = (window as any).__templateApp;
return templateApp?.getLocale?.() || 'ko';
},
/**
* 지원하는 로케일 목록을 반환합니다.
*/
supported: () => {
const templateApp = (window as any).__templateApp;
const systemLocales = templateApp?.globalState?.appConfig?.supportedLocales;
if (Array.isArray(systemLocales) && systemLocales.length > 0) {
return systemLocales;
}
const state = deps.getState();
return state.templateMetadata?.locales || ['ko', 'en'];
},
/**
* 로케일을 변경합니다.
* @param locale 변경할 로케일 (예: 'ko', 'en')
*/
change: async (locale: string) => {
const templateApp = (window as any).__templateApp;
if (templateApp?.changeLocale) {
await templateApp.changeLocale(locale);
} else {
logger.warn('G7Core.locale.change: TemplateApp이 초기화되지 않았습니다.');
}
},
};
logger.log('전역 객체 window.G7Core.locale에 노출됨');
}
/**
* 토스트 알림 API 초기화
*/
function initToastAPI(G7Core: any): void {
G7Core.toast = {
/**
* 토스트 알림을 표시합니다.
* @param message 표시할 메시지
* @param options type: 'success'|'error'|'warning'|'info', duration: ms
*/
show: (message: string, options?: { type?: 'success' | 'error' | 'warning' | 'info'; duration?: number }) => {
G7Core.dispatch({
handler: 'toast',
params: {
message,
type: options?.type || 'info',
...(options?.duration && { duration: options.duration }),
},
});
},
success: (message: string, duration?: number) => {
G7Core.toast.show(message, { type: 'success', duration });
},
error: (message: string, duration?: number) => {
G7Core.toast.show(message, { type: 'error', duration });
},
warning: (message: string, duration?: number) => {
G7Core.toast.show(message, { type: 'warning', duration });
},
info: (message: string, duration?: number) => {
G7Core.toast.show(message, { type: 'info', duration });
},
};
logger.log('전역 객체 window.G7Core.toast에 노출됨');
}
/**
* 모달 관리 API 초기화
*/
function initModalAPI(G7Core: any): void {
G7Core.modal = {
/**
* 모달을 엽니다.
* @param modalId 열 모달의 ID
*/
open: (modalId: string) => {
G7Core.dispatch({
handler: 'openModal',
target: modalId,
});
},
/**
* 모달을 닫습니다.
* @param modalId 닫을 모달의 ID (생략 시 최상위 모달 닫기)
*/
close: (modalId?: string) => {
G7Core.dispatch({
handler: 'closeModal',
...(modalId && { target: modalId }),
});
},
/**
* 모든 모달을 닫습니다.
*/
closeAll: () => {
G7Core.dispatch({
handler: 'closeAllModals',
});
},
/**
* 특정 모달이 열려있는지 확인합니다.
* @param modalId 확인할 모달의 ID
*/
isOpen: (modalId: string) => {
const state = G7Core.state.get();
return state._global?.activeModal === modalId ||
state._global?.modalStack?.includes(modalId) ||
false;
},
/**
* 현재 열린 모달 스택을 반환합니다.
*/
getStack: () => {
const state = G7Core.state.get();
return state._global?.modalStack || [];
},
};
logger.log('전역 객체 window.G7Core.modal에 노출됨');
}
/**
* 스타일 헬퍼 API 초기화
*/
function initStyleAPI(G7Core: any): void {
G7Core.style = {
/**
* Tailwind CSS 클래스를 런타임에서 병합합니다.
* 같은 CSS 속성을 제어하는 클래스가 충돌하면 override 클래스를 우선 적용합니다.
*
* @example
* ```typescript
* G7Core.style.mergeClasses('justify-center items-center', 'justify-between')
* // 결과: 'items-center justify-between'
* ```
*/
mergeClasses,
/**
* 조건에 따라 클래스를 적용합니다.
*
* @example
* ```typescript
* G7Core.style.conditionalClass({
* 'bg-blue-500': isPrimary,
* 'opacity-50': isDisabled,
* })
* ```
*/
conditionalClass,
/**
* 여러 클래스 문자열을 하나로 합칩니다.
* falsy 값은 무시됩니다.
*
* @example
* ```typescript
* G7Core.style.joinClasses('flex', isActive && 'bg-blue-500', 'p-4')
* ```
*/
joinClasses,
};
logger.log('전역 객체 window.G7Core.style에 노출됨');
}
/**
* WebSocket 관리 API 초기화
*/
function initWebSocketAPI(G7Core: any, deps: G7CoreDependencies): void {
const { webSocketManager } = deps;
G7Core.websocket = {
/**
* WebSocketManager 인스턴스를 반환합니다.
*/
manager: webSocketManager,
/**
* WebSocket 채널을 구독합니다.
* @param channel 채널명 (예: 'admin.dashboard')
* @param event 이벤트명 (예: 'dashboard.stats.updated')
* @param callback 이벤트 수신 시 호출될 콜백
* @param options 구독 옵션 { channelType: 'public' | 'private' | 'presence' }
* @returns 구독 키 (구독 해제 시 사용)
*/
subscribe: (
channel: string,
event: string,
callback: (data: unknown) => void,
options?: { channelType?: 'public' | 'private' | 'presence' }
) => webSocketManager.subscribe(channel, event, callback, options),
/**
* WebSocket 구독을 해제합니다.
* @param subscriptionKey 구독 키
*/
unsubscribe: (subscriptionKey: string) => webSocketManager.unsubscribe(subscriptionKey),
/**
* 특정 채널의 모든 구독을 해제합니다.
* @param channel 채널명
*/
leaveChannel: (channel: string) => webSocketManager.leaveChannel(channel),
/**
* 모든 WebSocket 연결을 종료합니다.
*/
disconnect: () => webSocketManager.disconnect(),
/**
* WebSocket 초기화 여부를 반환합니다.
*/
isInitialized: () => webSocketManager.isInitialized(),
/**
* 현재 활성 구독 수를 반환합니다.
*/
getSubscriptionCount: () => webSocketManager.getSubscriptionCount(),
};
logger.log('전역 객체 window.G7Core.websocket에 노출됨');
}
/**
* 네비게이션 API 초기화
*/
function initNavigationAPI(G7Core: any, deps: G7CoreDependencies): void {
const { transitionManager } = deps;
G7Core.navigation = {
/**
* 현재 페이지 전환이 진행 중인지 확인합니다.
* @returns 전환 진행 중이면 true
*/
isPending: () => {
return transitionManager?.getIsPending?.() || false;
},
/**
* 다음 페이지 전환이 완료되면 콜백을 실행합니다.
* 전환이 시작(isPending=true)된 후 완료(isPending=false)될 때 콜백을 실행합니다.
* @param callback 전환 완료 시 실행할 콜백
* @returns 구독 해제 함수
*/
onComplete: (callback: () => void) => {
if (!transitionManager) {
// TransitionManager가 없으면 즉시 실행
callback();
return () => {};
}
// 전환 시작 여부 추적
let transitionStarted = transitionManager.getIsPending();
// 전환 상태 변화 감지 (true → false 순서로 감지되면 콜백 실행)
const unsubscribe = transitionManager.subscribe((isPending: boolean) => {
if (isPending) {
// 전환 시작됨
transitionStarted = true;
} else if (transitionStarted) {
// 전환이 시작된 후 완료됨
callback();
unsubscribe();
}
});
return unsubscribe;
},
};
logger.log('전역 객체 window.G7Core.navigation에 노출됨');
}
/**
* 플러그인 설정 접근 API 초기화
*
* 활성화된 플러그인의 환경설정 값에 접근합니다.
* admin.blade.php에서 G7Config.plugins로 주입된 값을 사용합니다.
*
* @example
* ```ts
* // 플러그인 전체 설정 조회
* const daumSettings = G7Core.plugin.getSettings('sirsoft-daum_postcode');
* // { display_mode: 'layer', popup_width: 900, ... }
*
* // 특정 설정 값 조회
* const displayMode = G7Core.plugin.get('sirsoft-daum_postcode', 'display_mode');
* // 'layer'
*
* // 기본값과 함께 조회
* const width = G7Core.plugin.get('sirsoft-daum_postcode', 'popup_width', 500);
* ```
*/
function initPluginAPI(G7Core: any): void {
G7Core.plugin = {
/**
* 플러그인의 전체 설정을 조회합니다.
* @param identifier 플러그인 식별자
* @returns 플러그인 설정 객체 또는 undefined
*/
getSettings: (identifier: string): Record<string, any> | undefined => {
return (window as any).G7Config?.plugins?.[identifier];
},
/**
* 플러그인의 특정 설정 값을 조회합니다.
* @param identifier 플러그인 식별자
* @param key 설정 키
* @param defaultValue 기본값
* @returns 설정 값 또는 기본값
*/
get: (identifier: string, key: string, defaultValue?: any): any => {
const settings = (window as any).G7Config?.plugins?.[identifier];
return settings?.[key] ?? defaultValue;
},
/**
* 모든 활성화된 플러그인 설정을 조회합니다.
* @returns 플러그인 식별자를 키로 하는 설정 객체
*/
getAll: (): Record<string, Record<string, any>> => {
return (window as any).G7Config?.plugins ?? {};
},
};
logger.log('전역 객체 window.G7Core.plugin에 노출됨');
}
/**
* 모듈 설정 접근 API 초기화
*
* 활성화된 모듈의 환경설정 값에 접근합니다.
* admin.blade.php에서 G7Config.modules로 주입된 값을 사용합니다.
*
* @example
* ```ts
* // 모듈 전체 설정 조회
* const ecommerceSettings = G7Core.module.getSettings('sirsoft-ecommerce');
*
* // 특정 설정 값 조회
* const currency = G7Core.module.get('sirsoft-ecommerce', 'default_currency', 'KRW');
* ```
*/
function initModuleAPI(G7Core: any): void {
G7Core.module = {
/**
* 모듈의 전체 설정을 조회합니다.
* @param identifier 모듈 식별자
* @returns 모듈 설정 객체 또는 undefined
*/
getSettings: (identifier: string): Record<string, any> | undefined => {
return (window as any).G7Config?.modules?.[identifier];
},
/**
* 모듈의 특정 설정 값을 조회합니다.
* @param identifier 모듈 식별자
* @param key 설정 키
* @param defaultValue 기본값
* @returns 설정 값 또는 기본값
*/
get: (identifier: string, key: string, defaultValue?: any): any => {
const settings = (window as any).G7Config?.modules?.[identifier];
return settings?.[key] ?? defaultValue;
},
/**
* 모든 활성화된 모듈 설정을 조회합니다.
* @returns 모듈 식별자를 키로 하는 설정 객체
*/
getAll: (): Record<string, Record<string, any>> => {
return (window as any).G7Config?.modules ?? {};
},
};
logger.log('전역 객체 window.G7Core.module에 노출됨');
}
/**
* 코어 API 초기화 (AuthManager, ApiClient, 훅 등)
*/
function initCoreAPIs(G7Core: any, deps: G7CoreDependencies): void {
const { transitionManager, responsiveManager } = deps;
// AuthManager 노출 (템플릿 컴포넌트에서 사용자 정보 접근용)
G7Core.AuthManager = AuthManager;
// ApiClient 노출 (템플릿 컴포넌트에서 API 호출용)
G7Core.api = getApiClient();
// TransitionManager 노출 (템플릿 컴포넌트에서 페이지 전환 상태 접근용)
// 동기적으로 노출하여 컴포넌트 마운트 시점에 바로 사용 가능하도록 함
G7Core.TransitionManager = transitionManager;
// useTransitionState 훅 노출 (템플릿 컴포넌트에서 전환 상태 구독용)
G7Core.useTransitionState = useTransitionState;
// ResponsiveManager 노출 (템플릿 컴포넌트에서 반응형 상태 접근용)
G7Core.ResponsiveManager = responsiveManager;
// useResponsive 훅 노출 (템플릿 컴포넌트에서 반응형 상태 구독용)
G7Core.useResponsive = useResponsive;
// useControllableState 훅 노출 (Phase 1-4)
// Controlled/Uncontrolled 상태 패턴을 단순화하는 훅
G7Core.useControllableState = useControllableState;
G7Core.shallowArrayEqual = shallowArrayEqual;
G7Core.shallowObjectEqual = shallowObjectEqual;
// updateQueryParams 노출 (같은 페이지 내 쿼리 변경 시 컴포넌트 리마운트 없이 데이터 갱신)
// navigate 핸들러에서 replace: true 사용 시 자동 호출됨
// options.transitionOverlayTarget: 호출별 transition_overlay.target 동적 override (@since engine-v1.36.0)
G7Core.updateQueryParams = async (
newPath: string,
options?: { transitionOverlayTarget?: string }
) => {
const templateApp = (window as any).__templateApp;
if (templateApp?.updateQueryParams) {
return templateApp.updateQueryParams(newPath, options);
}
logger.warn('G7Core.updateQueryParams: TemplateApp이 초기화되지 않았습니다.');
};
logger.log('전역 객체 window.G7Core.AuthManager에 노출됨');
logger.log('전역 객체 window.G7Core.api에 노출됨');
logger.log('전역 객체 window.G7Core.ResponsiveManager에 노출됨');
logger.log('전역 객체 window.G7Core.useResponsive에 노출됨');
logger.log('전역 객체 window.G7Core.useControllableState에 노출됨');
logger.log('전역 객체 window.G7Core.updateQueryParams에 노출됨');
}
/**
* 슬롯 시스템 API 초기화
*
* SlotContext와 DynamicRenderer를 외부(템플릿 컴포넌트)에서 접근할 수 있도록 합니다.
* SlotContainer 컴포넌트에서 이 API를 사용하여 슬롯에 등록된 컴포넌트를 렌더링합니다.
*
* @example
* ```ts
* // SlotContainer.tsx에서 사용
* const slotContext = G7Core.getSlotContext();
* const DynamicRenderer = G7Core.getDynamicRenderer();
* const registry = G7Core.getComponentRegistry();
* ```
*/
function initSlotAPI(G7Core: any, deps: G7CoreDependencies): void {
/**
* SlotContext 값을 가져옵니다.
* SlotProvider 내부에서만 유효한 값을 반환합니다.
*
* SlotProvider가 마운트되면 window.__slotContextValue에 값이 설정됩니다.
*/
G7Core.getSlotContext = () => {
// SlotProvider에서 설정한 전역 값 참조
return (window as any).__slotContextValue ?? null;
};
/**
* DynamicRenderer 컴포넌트를 가져옵니다.
* SlotContainer에서 슬롯에 등록된 컴포넌트를 렌더링할 때 사용합니다.
*
* DynamicRenderer가 로드되면 window.__DynamicRenderer에 값이 설정됩니다.
*/
G7Core.getDynamicRenderer = () => {
// DynamicRenderer 모듈에서 설정한 전역 값 참조
return (window as any).__DynamicRenderer ?? null;
};
/**
* ComponentRegistry를 가져옵니다.
*/
G7Core.getComponentRegistry = () => {
return deps.getComponentRegistry?.() ?? ComponentRegistry.getInstance();
};
/**
* DataBindingEngine을 가져옵니다.
*/
G7Core.getDataBindingEngine = () => {
return deps.getDataBindingEngine?.() ?? deps.getState().bindingEngine;
};
/**
* TranslationEngine을 가져옵니다.
*/
G7Core.getTranslationEngine = () => {
return deps.getTranslationEngine?.() ?? deps.getState().translationEngine;
};
/**
* ActionDispatcher를 가져옵니다.
*/
G7Core.getActionDispatcher = () => {
return deps.getActionDispatcher?.() ?? deps.getState().actionDispatcher;
};
logger.log('전역 객체 window.G7Core 슬롯 API에 노출됨 (getSlotContext, getDynamicRenderer 등)');
}
/**
* G7Core.identity 인터페이스 초기화 (engine-v1.46.0+)
*
* 본인인증(IDV) 인터셉터를 템플릿/플러그인이 동일 인스턴스로 공유하기 위한 글로벌 진입점입니다.
* 템플릿 번들이 IIFE 로 빌드되면서 코어 모듈을 외부화하지 않으므로, 직접 import 대신
* 이 namespace 를 통해 코어의 단일 IdentityGuardInterceptor 정적 클래스에 접근해야 합니다.
*
* 노출되는 진입점:
* - `setLauncher(launcher)` — 템플릿 부트스트랩에서 모달 launcher 등록
* - `redirectExternally(payload)` — external_redirect 흐름 helper
* - `createDeferred()` / `resolveDeferred(result)` — launcher 가 모달 결과를 await 하기 위한 deferred
* - `hasLauncher()` / `reset()` — 진단/테스트용
* - `redirectStashKey` — sessionStorage stash 키 상수
*
* @param G7Core - window.G7Core 네임스페이스
*/
function initIdentityAPI(G7Core: any): void {
G7Core.identity = {
/** 모달 launcher 등록 (템플릿 부트스트랩에서 호출) */
setLauncher: IdentityGuardInterceptor.setLauncher.bind(IdentityGuardInterceptor),
/**
* 428 응답을 처리합니다 (challenge 모달 launcher 호출 후 성공 시 원 요청 재실행).
*
* ActionDispatcher.handleApiCall 의 자동 인터셉트와 동일한 launcher 플로우를,
* 직접 fetch/G7Core.api 로 요청하는 모듈 JS 핸들러(예: 입금확인)가 재사용하기 위한 진입점.
* verify 성공 시 재실행 Response 를, 취소/실패/return_request 부재 시 null 을 반환한다.
*/
handle: IdentityGuardInterceptor.handle.bind(IdentityGuardInterceptor),
/** 응답이 428 IDV 요구 응답인지 판별 */
isIdentityRequired: IdentityGuardInterceptor.isIdentityRequired.bind(IdentityGuardInterceptor),
/** 등록된 launcher 가 있는지 여부 */
hasLauncher: IdentityGuardInterceptor.hasLauncher.bind(IdentityGuardInterceptor),
/**
* challenge 가 자기 고유의 도메인 안내(성인인증 실패 등)를 사용자에게 표출했음을 표시.
* 호출 시 코어 toast 핸들러가 동일 사이클의 generic IDV 가드 토스트("본인 확인이 필요합니다") 1건을 skip.
* provider 플러그인이 "본인확인 성공 + 부가목적 미달" 실패를 안내한 직후 호출한다.
*/
markDomainNoticeShown: IdentityGuardInterceptor.markDomainNoticeShown.bind(IdentityGuardInterceptor),
/** external_redirect 흐름 — sessionStorage stash + window.location 이동 */
redirectExternally: IdentityGuardInterceptor.redirectExternally.bind(IdentityGuardInterceptor),
/** launcher 가 모달 결과를 await 하기 위한 deferred Promise 생성 */
createDeferred: IdentityGuardInterceptor.createDeferred.bind(IdentityGuardInterceptor),
/** 모달/풀페이지가 verify 결과를 deferred resolver 에 통보 (resolveIdentityChallenge 핸들러 내부에서 사용) */
resolveDeferred: IdentityGuardInterceptor.resolveDeferred.bind(IdentityGuardInterceptor),
/** 테스트/진단용 — launcher 와 deferred 슬롯 초기화 */
reset: IdentityGuardInterceptor.reset.bind(IdentityGuardInterceptor),
/** sessionStorage stash 키 상수 */
redirectStashKey: IDENTITY_REDIRECT_STASH_KEY,
};
logger.log('전역 객체 window.G7Core 본인인증(IDV) API에 노출됨 (identity)');
}
/**
* G7Core 전역 객체를 초기화합니다.
*
* 템플릿 컴포넌트에서 사용할 수 있는 모든 전역 API를 window.G7Core에 노출합니다.
*
* @param deps - 초기화에 필요한 의존성 객체
*/
export function initializeG7CoreGlobals(deps: G7CoreDependencies): void {
if (typeof window === 'undefined') {
return;
}
// React 전역 노출
initReactGlobals();
// G7Core 네임스페이스가 없으면 생성 (TemplateApp.ts에서 이미 생성했을 수 있음)
if (!(window as any).G7Core) {
(window as any).G7Core = {};
}
const G7Core = (window as any).G7Core;
// 코어 런타임 표면 노출 (편집기 lazy 번들이 __runtime 재export shim 으로 공유)
initCoreRuntimeExports(G7Core);
// 각 API 그룹 초기화
initComponentEventSystem(G7Core);
// 레이아웃 편집기 확장점 예약 접수함(편집기 lazy 로드 전 템플릿 등록을 큐에 보존)
initLayoutEditorStub(G7Core);
initCoreAPIs(G7Core, deps);
initAssetUrlAPI(G7Core);
initTranslationAPI(G7Core, deps);
initHelperAPIs(G7Core, deps);
initDispatchAPI(G7Core);
initStateAPI(G7Core);
initDataSourceAPI(G7Core);
initLocaleAPI(G7Core, deps);
initToastAPI(G7Core);
initModalAPI(G7Core);
initStyleAPI(G7Core);
initWebSocketAPI(G7Core, deps);
initNavigationAPI(G7Core, deps);
initPluginAPI(G7Core);
initModuleAPI(G7Core);
initSlotAPI(G7Core, deps);
initIdentityAPI(G7Core);
logger.log('G7Core 전역 객체 초기화 완료');
// DevTools API는 initTemplateApp 이후에 초기화됨 (debug 옵션 확인 필요)
// initDevToolsAPI는 reinitializeDevTools()를 통해 호출됨
}
/**
* G7Core.devTools 인터페이스 초기화
*
* G7DevToolsCore.getInstance() 직접 호출 대신 통합 인터페이스를 통해 추적 기능에 접근합니다.
* 템플릿 컴포넌트, 헬퍼 함수, 모듈에서 일관된 방식으로 DevTools에 접근할 수 있습니다.
*
* @example
* ```ts
* // renderItemChildren 등에서 사용
* const G7Core = (window as any).G7Core;
* if (G7Core?.devTools?.isEnabled()) {
* G7Core.devTools.trackIteration(id, source, itemVar, indexVar, itemCount);
* }
* ```
*/
export function initDevToolsInterface(): void {
const G7Core = (window as any).G7Core;
if (!G7Core) {
logger.warn('G7Core가 초기화되지 않았습니다.');
return;
}
const getDevTools = () => G7DevToolsCore.getInstance();
const devToolsInterface: G7DevToolsInterface = {
isEnabled: () => {
try {
return getDevTools().isEnabled();
} catch {
return false;
}
},
trackRender: (componentName: string) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackRender(componentName);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackIteration: (id, source, itemVar, indexVar, itemCount) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackIteration(id, {
source,
itemVar,
indexVar,
sourceLength: itemCount,
});
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackIfCondition: (id, condition, result, componentName) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackIfCondition(id, condition, result);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackMount: (componentId, info) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackMount(componentId, { ...info, id: componentId });
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackUnmount: (componentId) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackUnmount(componentId);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackExpressionEval: (info) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackExpressionEval(info);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackBindingEval: (expression?: string) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackBindingEval(expression);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
recordCacheHit: () => {
try {
if (getDevTools().isEnabled()) {
getDevTools().recordCacheHit();
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
recordCacheMiss: () => {
try {
if (getDevTools().isEnabled()) {
getDevTools().recordCacheMiss();
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackHandlerRegistration: (name, category, description, source) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackHandlerRegistration(name, category, description, source);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackHandlerUnregistration: (name) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackHandlerUnregistration(name);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
logAction: (action) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().logAction(action);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackRequest: (url, method) => {
try {
if (getDevTools().isEnabled()) {
return getDevTools().trackRequest(url, method);
}
return '';
} catch (e) {
return '';
}
},
completeRequest: (requestId, statusCode, response) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().completeRequest(requestId, statusCode, response);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
failRequest: (requestId, error) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().failRequest(requestId, error);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackDataSourceDefinition: (info) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackDataSourceDefinition(info);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackDataSourceLoading: (id) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackDataSourceLoading(id);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackDataSourceLoaded: (id, data, dataPath) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackDataSourceLoaded(id, data, dataPath);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackDataSourceError: (id, error) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackDataSourceError(id, error);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackForm: (id, dataKey, inputs) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackForm(id, dataKey, inputs);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
untrackForm: (id) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().untrackForm(id);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
startStateChange: (statePath, oldValue, newValue, trigger) => {
try {
if (getDevTools().isEnabled()) {
return getDevTools().startStateChange(statePath, oldValue, newValue, trigger);
}
return '';
} catch (e) {
return '';
}
},
completeStateChange: (setStateId) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().completeStateChange(setStateId);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackComponentRender: (componentId, componentName, renderDuration, accessedStatePaths, evaluatedBindings, parentId) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackComponentRender(
componentId,
componentName,
renderDuration,
accessedStatePaths,
evaluatedBindings,
parentId
);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
updateLocalState: (localState) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().updateLocalState(localState);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
updateComputedState: (computedState) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().updateComputedState(computedState);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
updateParentContext: (parentContext) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().updateParentContext(parentContext);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackComponentStateSource: (componentId, componentName, stateSource, stateProvider) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackComponentStateSource(componentId, componentName, stateSource, stateProvider);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackDynamicState: (componentId, dynamicState) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackDynamicState(componentId, dynamicState);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackContextFlow: (componentId, componentName, contextReceived, passedToChildren, usedInRender, parentId) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackContextFlow(componentId, componentName, contextReceived, passedToChildren, usedInRender, parentId);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackComponentStyle: (componentId, componentName, classes, computedStyles) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackComponentStyle(componentId, componentName, classes, computedStyles);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackAuthEvent: (type, success, error, details) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackAuthEvent(type, success, error, details);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackAuthHeader: (url, hasAuthHeader, headerType, tokenValid, responseStatus) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackAuthHeader(url, hasAuthHeader, headerType, tokenValid, responseStatus);
}
} catch (e) {
// DevTools 추적 실패해도 렌더링은 계속
}
},
trackLog: (level, prefix, args) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackLog(level, prefix, args);
}
} catch {
// DevTools 추적 실패해도 로깅은 계속
}
},
trackAction: (info) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackAction?.(info);
}
} catch (e) {
// DevTools 추적 실패해도 액션 실행은 계속
}
},
trackDataSourceUpdate: (info) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackDataSourceUpdate?.(info);
}
} catch (e) {
// DevTools 추적 실패해도 업데이트는 계속
}
},
// ============================================
// Sequence 실행 추적 메서드
// ============================================
startSequenceExecution: (trigger) => {
try {
if (getDevTools().isEnabled()) {
return getDevTools().startSequenceExecution(trigger);
}
return '';
} catch (e) {
return '';
}
},
captureSequenceActionBefore: (sequenceId, actionIndex, handler, params, currentState) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().captureSequenceActionBefore(sequenceId, actionIndex, handler, params, currentState);
}
} catch (e) {
// DevTools 추적 실패해도 실행은 계속
}
},
captureSequenceActionAfter: (sequenceId, actionIndex, currentState, duration, result, error) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().captureSequenceActionAfter(sequenceId, actionIndex, currentState, duration, result, error);
}
} catch (e) {
// DevTools 추적 실패해도 실행은 계속
}
},
endSequenceExecution: (sequenceId, error) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().endSequenceExecution(sequenceId, error);
}
} catch (e) {
// DevTools 추적 실패해도 실행은 계속
}
},
// ============================================
// Stale Closure 감지 메서드
// ============================================
registerStateCaptureForHandler: (handlerId, statePaths, stateValues) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().registerStateCaptureForHandler(handlerId, statePaths, stateValues);
}
} catch (e) {
// DevTools 추적 실패해도 실행은 계속
}
},
detectStaleClosure: (handlerId, location, currentState, warningType, actionId) => {
try {
if (getDevTools().isEnabled()) {
return getDevTools().detectStaleClosure(handlerId, location, currentState, warningType, actionId);
}
return [];
} catch (e) {
return [];
}
},
trackStaleClosureWarning: (info) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackStaleClosureWarning(info);
}
} catch (e) {
// DevTools 추적 실패해도 실행은 계속
}
},
// ============================================
// Modal State Scope 추적 메서드
// ============================================
trackModalOpen: (options) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackModalOpen(options);
}
} catch (e) {
// DevTools 추적 실패해도 실행은 계속
}
},
trackModalClose: (modalId, finalState) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackModalClose(modalId, finalState);
}
} catch (e) {
// DevTools 추적 실패해도 실행은 계속
}
},
trackModalStateChange: (options) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackModalStateChange(options);
}
} catch (e) {
// DevTools 추적 실패해도 실행은 계속
}
},
// ============================================
// Nested Context 추적 메서드
// ============================================
trackNestedContext: (info) => {
try {
if (getDevTools().isEnabled()) {
return getDevTools().trackNestedContext(info);
}
return '';
} catch (e) {
return '';
}
},
trackNestedContextAccess: (contextId, access) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackNestedContextAccess(contextId, access);
}
} catch (e) {
// DevTools 추적 실패해도 실행은 계속
}
},
// ============================================
// Computed 추적 메서드
// ============================================
trackComputedProperty: (name, expression, dependencies, value, computationTime, componentId, error) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackComputedProperty(name, expression, dependencies as any, value, computationTime, componentId, error);
}
} catch (e) {
// DevTools 추적 실패해도 실행은 계속
}
},
trackComputedRecalc: (computedName, trigger, previousValue, newValue, computationTime, triggeredBy, componentId) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackComputedRecalc(computedName, trigger as any, previousValue, newValue, computationTime, triggeredBy as any, componentId);
}
} catch (e) {
// DevTools 추적 실패해도 실행은 계속
}
},
setNamedActionDefinitions: (definitions) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().setNamedActionDefinitions(definitions);
}
} catch (e) {
// DevTools 추적 실패해도 실행은 계속
}
},
trackNamedActionRef: (log) => {
try {
if (getDevTools().isEnabled()) {
getDevTools().trackNamedActionRef(log);
}
} catch (e) {
// DevTools 추적 실패해도 실행은 계속
}
},
};
// G7Core.devTools에 인터페이스 할당
G7Core.devTools = devToolsInterface;
logger.log('G7Core.devTools 인터페이스 초기화 완료');
}
/**
* G7 DevTools API 초기화
*
* 디버깅 및 개발 도구 API를 window.G7DevTools에 노출합니다.
* 환경설정의 debug_mode가 활성화된 경우에만 전체 기능이 활성화됩니다.
*
* initTemplateApp에서 debug 옵션 설정 후 호출해야 합니다.
*
* @example
* ```ts
* // 상태 조회
* G7DevTools.state.get()
*
* // 액션 이력 조회
* G7DevTools.actions.getHistory()
*
* // 자동 진단
* G7DevTools.diagnose.analyze(['이전 값이 전송됨'])
*
* // 서버로 상태 덤프
* G7DevTools.server.dumpState()
* ```
*/
/**
* DevTools 디버그 전용 모듈(패널 UI/진단엔진/서버커넥터/스타일추적기)의 lazy 번들 타입.
* @since engine-v1.51.0
*/
interface DevToolsBundle {
DiagnosticEngine: any;
getServerConnector: () => any;
getStyleTracker: () => any;
DevToolsPanel: any;
}
let devToolsBundleLoadPromise: Promise<DevToolsBundle> | null = null;
/**
* DevTools lazy 번들(devtools.min.js)을 로드하고 디버그 전용 모듈 4종을 반환한다.
*
* @since engine-v1.51.0
*
* 디버그 모드에서만 호출된다. 이미 로드됨(멱등)/진행 중(in-flight 병합)/미로드(1회 주입)
* 를 구분한다.
*
* @returns DevTools 디버그 전용 모듈 묶음
* @throws {Error} 스크립트 로드 실패 또는 모듈 미노출 시
*/
export function loadDevToolsBundle(): Promise<DevToolsBundle> {
const G7Core = (window as any).G7Core;
if (G7Core?.__devtools) {
return Promise.resolve(G7Core.__devtools as DevToolsBundle);
}
if (devToolsBundleLoadPromise) {
return devToolsBundleLoadPromise;
}
devToolsBundleLoadPromise = new Promise<DevToolsBundle>((resolve, reject) => {
const src = (window as any).G7Config?.coreDevToolsAsset || '/build/core/devtools.min.js';
const scriptId = 'g7-devtools-bundle';
const onReady = () => {
const bundle = (window as any).G7Core?.__devtools;
if (bundle) {
resolve(bundle as DevToolsBundle);
} else {
reject(new Error('DevTools 번들 로드됨 — 그러나 __devtools 미노출'));
}
};
(window as any).G7Core = (window as any).G7Core || {};
(window as any).G7Core.__onDevToolsReady = onReady;
const existing = document.getElementById(scriptId) as HTMLScriptElement | null;
if (existing) {
existing.addEventListener('load', onReady, { once: true });
existing.addEventListener(
'error',
() => reject(new Error(`DevTools 번들 로드 실패: ${src}`)),
{ once: true }
);
return;
}
const script = document.createElement('script');
script.id = scriptId;
script.src = src;
script.async = false;
script.addEventListener('load', onReady, { once: true });
script.addEventListener(
'error',
() => {
devToolsBundleLoadPromise = null;
reject(new Error(`DevTools 번들 로드 실패: ${src}`));
},
{ once: true }
);
document.head.appendChild(script);
});
return devToolsBundleLoadPromise;
}
export function initDevToolsAPI(): void {
const G7Core = (window as any).G7Core;
if (!G7Core) {
logger.warn('G7Core가 초기화되지 않았습니다.');
return;
}
const devToolsCore = G7DevToolsCore.getInstance();
devToolsCore.initialize();
// DevTools가 비활성화된 경우 최소 API만 노출 (devtools.min.js 미로드 — 초기 payload 절감)
if (!devToolsCore.isEnabled()) {
(window as any).G7DevTools = {
isEnabled: () => false,
enable: () => {
logger.log('환경설정 > 고급 설정 > 디버그 모드를 켜세요');
},
};
// 비활성화 상태에서도 버퍼 정리 (보안)
flushEarlyLogs();
logger.log('G7DevTools 비활성화됨 (디버그 모드 꺼짐)');
return;
}
// 디버그 모드 — DevTools lazy 번들 로드 후 전체 API 활성화 (fire-and-forget)
void setupDevToolsWhenEnabled(G7Core, devToolsCore);
}
/**
* 디버그 모드에서 DevTools lazy 번들을 로드하고 전체 API 를 활성화한다.
*
* @since engine-v1.51.0
*
* @param G7Core - window.G7Core 전역 객체
* @param devToolsCore - G7DevToolsCore 싱글톤 인스턴스
*/
async function setupDevToolsWhenEnabled(G7Core: any, devToolsCore: any): Promise<void> {
let bundle: DevToolsBundle;
try {
bundle = await loadDevToolsBundle();
} catch (error) {
logger.warn('DevTools 번들 로드 실패 — 최소 API 로 폴백:', error);
(window as any).G7DevTools = { isEnabled: () => false, enable: () => {} };
flushEarlyLogs();
return;
}
const { DiagnosticEngine, getServerConnector, getStyleTracker } = bundle;
// G7Core.devTools 인터페이스 초기화 (renderItemChildren 등에서 사용)
initDevToolsInterface();
// 초기화 전에 버퍼링된 에러/경고 로그 flush
flushEarlyLogs();
// StyleTracker 활성화 (DOM 스타일 이상 감지)
try {
const styleTracker = getStyleTracker();
styleTracker.enable();
logger.log('StyleTracker 활성화됨');
} catch (error) {
logger.warn('StyleTracker 활성화 실패:', error);
}
const diagnosticEngine = new DiagnosticEngine();
const serverConnector = getServerConnector();
const G7DevTools = {
// === 활성화 상태 ===
isEnabled: () => devToolsCore.isEnabled(),
// === 상태 관리 ===
state: {
/**
* 현재 상태를 조회합니다.
* @returns { _global, _local, _computed }
*/
get: () => devToolsCore.getState(),
/**
* 상태 변경 이력을 조회합니다.
* @returns StateSnapshot[]
*/
getHistory: () => devToolsCore.getStateHistory(),
/**
* 상태 변경을 구독합니다.
* @param path 감시할 경로 ('*'는 모든 변경)
* @param callback 변경 시 호출될 콜백
* @returns 구독 해제 함수
*/
watch: (path: string, callback: (newVal: any, oldVal: any) => void) =>
devToolsCore.watchState(path, callback),
},
// === 액션 추적 ===
actions: {
/**
* 액션 실행 이력을 조회합니다.
* @returns ActionLog[]
*/
getHistory: () => devToolsCore.getActionHistory(),
/**
* 액션 실행을 구독합니다.
* @param callback 액션 실행 시 호출될 콜백
* @returns 구독 해제 함수
*/
watch: (callback: (action: any) => void) => devToolsCore.watchActions(callback),
/**
* 액션 실행 메트릭을 조회합니다.
* @returns ActionMetrics
*/
getMetrics: () => devToolsCore.getActionMetrics(),
},
// === 바인딩 & 캐시 ===
binding: {
/**
* 표현식을 평가합니다.
* @param expr 평가할 표현식
* @returns 평가 결과
*/
evaluate: (expr: string) => {
const engine = G7Core.getDataBindingEngine?.();
if (!engine) {
logger.warn('DataBindingEngine이 없습니다.');
return undefined;
}
return engine.evaluateExpression(expr, devToolsCore.getState());
},
/**
* 캐시 통계를 조회합니다.
* @returns CacheStats
*/
getCacheStats: () => devToolsCore.getCacheStats(),
/**
* 캐시 통계를 초기화합니다.
*/
clearStats: () => {
devToolsCore.resetCacheStats();
logger.log('캐시 통계 초기화됨');
},
},
// === 자동 진단 ===
diagnose: {
/**
* 증상을 기반으로 문제를 진단합니다.
* @param symptoms 증상 배열
* @returns DiagnosisResult[]
*/
analyze: (symptoms: string[]) => diagnosticEngine.analyze(symptoms),
/**
* 진단 결과에 대한 수정 제안을 반환합니다.
* @param diagnosis 진단 결과
* @returns FixSuggestion
*/
suggestFix: (diagnosis: any) => diagnosticEngine.suggestFix(diagnosis),
/**
* 자주 발생하는 문제 목록을 반환합니다.
* @returns CommonIssue[]
*/
getCommonIssues: () => diagnosticEngine.getCommonIssues(),
/**
* 카테고리별 진단 규칙을 조회합니다.
* @param category 카테고리
* @returns DiagnosticRule[]
*/
getRulesByCategory: (category: DiagnosticCategory) => diagnosticEngine.getRulesByCategory(category),
},
// === 서버 통합 ===
server: {
/**
* 현재 상태를 서버로 덤프합니다.
* @param saveHistory 이력 파일도 저장할지 여부
* @returns 저장된 파일 경로
*/
dumpState: (saveHistory?: boolean) => serverConnector.dumpState(saveHistory),
/**
* 디버그 로그를 서버로 전송합니다.
* @param data 로그 데이터
*/
sendLog: (data: any) => serverConnector.sendLog(data),
/**
* 에러 정보를 서버로 전송합니다.
* @param error 에러 객체
* @param context 추가 컨텍스트
*/
sendError: (error: Error, context?: Record<string, any>) =>
serverConnector.sendError(error, context),
/**
* 서버 연결 상태를 확인합니다.
* @returns 연결 상태
*/
isConnected: () => serverConnector.isConnected(),
/**
* 서버 연결을 테스트합니다.
* @returns 연결 성공 여부
*/
testConnection: () => serverConnector.testConnection(),
},
// === 설정 ===
config: {
/**
* 디버그 모드 상태를 확인합니다.
* @returns 디버그 모드 활성화 여부
*/
isDebugMode: () => devToolsCore.isEnabled(),
/**
* 로그 레벨을 설정합니다.
* @param level 로그 레벨
*/
setLogLevel: (level: 'debug' | 'info' | 'warn' | 'error') =>
devToolsCore.setLogLevel(level),
/**
* 최대 이력 개수를 설정합니다.
* @param count 최대 이력 개수
*/
setMaxHistory: (count: number) => devToolsCore.setMaxHistory(count),
},
// === 라이프사이클 ===
lifecycle: {
/**
* 마운트된 컴포넌트 목록을 조회합니다.
* @returns ComponentInfo[]
*/
getMountedComponents: () => devToolsCore.getLifecycleInfo().mountedComponents,
/**
* 정리되지 않은 이벤트 리스너를 조회합니다.
* @returns ListenerInfo[]
*/
getOrphanedListeners: () => devToolsCore.getLifecycleInfo().orphanedListeners,
},
// === 성능 ===
performance: {
/**
* 렌더링 횟수를 조회합니다.
* @returns Map<string, number>
*/
getRenderCount: () => devToolsCore.getPerformanceInfo().renderCounts,
/**
* 바인딩 평가 횟수를 조회합니다.
* @returns number
*/
getBindingEvalCount: () => devToolsCore.getPerformanceInfo().bindingEvalCount,
/**
* 메모리 경고를 조회합니다.
* @returns MemoryWarning[]
*/
getMemoryWarnings: () => devToolsCore.getPerformanceInfo().memoryWarnings,
/**
* 프로파일링을 시작합니다.
*/
startProfiling: () => devToolsCore.startProfiling(),
/**
* 프로파일링을 중지하고 결과를 반환합니다.
* @returns ProfileReport
*/
stopProfiling: () => devToolsCore.stopProfiling(),
},
// === 네트워크 ===
network: {
/**
* 진행 중인 요청을 조회합니다.
* @returns RequestInfo[]
*/
getActiveRequests: () => devToolsCore.getNetworkInfo().activeRequests,
/**
* 요청 이력을 조회합니다.
* @returns RequestLog[]
*/
getRequestHistory: () => devToolsCore.getNetworkInfo().requestHistory,
/**
* 대기 중인 데이터소스를 조회합니다.
* @returns string[]
*/
getPendingDataSources: () => devToolsCore.getNetworkInfo().pendingDataSources,
},
// === 조건부 렌더링 ===
conditional: {
/**
* if 조건 목록을 조회합니다.
* @returns ConditionInfo[]
*/
getIfConditions: () => devToolsCore.getConditionalInfo().ifConditions,
/**
* iteration 목록을 조회합니다.
* @returns IterationInfo[]
*/
getIterations: () => devToolsCore.getConditionalInfo().iterations,
},
// === Form ===
form: {
/**
* 추적된 Form 목록을 조회합니다.
* @returns FormInfo[]
*/
getForms: () => devToolsCore.getFormInfo(),
},
// === WebSocket ===
websocket: {
/**
* WebSocket 정보를 조회합니다.
* @returns WebSocketInfo
*/
getInfo: () => devToolsCore.getWebSocketInfo(),
},
};
// 전역 노출
(window as any).G7DevTools = G7DevTools;
// G7Core에도 DevTools 참조 추가
G7Core.devtools = G7DevTools;
logger.log('G7DevTools 전역 객체 초기화 완료 (window.G7DevTools)');
// DevToolsPanel UI 렌더링 (lazy 번들에서 로드한 컴포넌트 전달)
renderDevToolsPanel(bundle.DevToolsPanel);
}
/**
* DevToolsPanel UI를 DOM에 렌더링합니다.
*
* 별도의 DOM 컨테이너를 생성하여 메인 앱과 독립적으로 렌더링합니다.
* 디버그 모드가 활성화된 경우에만 렌더링됩니다.
*
* @param DevToolsPanel - lazy 번들(devtools.min.js)에서 로드한 패널 컴포넌트
*/
function renderDevToolsPanel(DevToolsPanel: any): void {
// 이미 렌더링되어 있으면 스킵
if (document.getElementById('g7-devtools-root')) {
logger.log('DevToolsPanel 이미 렌더링됨');
return;
}
// DOM 컨테이너 생성
const container = document.createElement('div');
container.id = 'g7-devtools-root';
document.body.appendChild(container);
// React root 생성 및 DevToolsPanel 렌더링
try {
const root = ReactDOM.createRoot(container);
root.render(React.createElement(DevToolsPanel));
logger.log('DevToolsPanel UI 렌더링 완료');
} catch (error) {
logger.error('DevToolsPanel UI 렌더링 실패:', error);
}
}