Files
Gnuboard7/resources/js/core/template-engine/DataSourceManager.ts
T
HeuJung 953616552f fix(core,engine,ecommerce,admin_basic): 렌더 경로 판정·파서 단일화로 표현식 비대칭 일괄 해소
표현식 해석 로직이 엔진 전역에 복제되면서 복사본마다 기능 집합과 판정 기준이
갈라졌고, 같은 작성이 어느 렌더 경로를 타느냐에 따라 다른 결과를 냈다. 증상
대부분이 예외가 아니라 조용한 오답 또는 값 소실이라 재현 위치를 특정하기 어려웠다.

판정만 BindingShape 단일 출처로 모으고 실행은 지점별로 유지했다. 전면 통합은
회귀 위험이 크고, 갈라진 것은 실행이 아니라 판정이었기 때문이다.

- 파이프 평가를 문자열 보간에 위임하면서 생긴 비대칭 3건 해소 (중괄호 노출 /
 _computed stale 전파 / 원본 타입 소실)
- 파이프 분기 누락 8지점 보강 — 평가 지점 모집단을 스캔해 가드를 강제하는 회귀선 신설
- 단일 바인딩·복잡식 판정 14곳을 정본으로 치환, 빈 바인딩 봉인
- BINDING_PATTERN 정규식을 수동 스캐너로 교체, resolveObject 를 key 단위 예외 격리로 전환
- iteration + 항목 참조 if 가 목록 전체를 지우던 결함 해소, 반복 경로 기능 격차 정렬
- 템플릿 컴포넌트의 new Function 자체 평가기 제거 → G7Core.evaluateCondition 위임
- 컨텍스트에 식별자가 아닌 키가 하나라도 있으면 그 컨텍스트의 모든 식이 평가되지
 못하던 결함 수정
- SEO 봇 렌더러도 같은 규칙으로 정렬 — React 만 고치면 봇 화면에서만 목록이 빈다
- audit walker 가 레이아웃의 74%(partial 루트 444/598)로 내려가지 못하던 사각 해소,
 신규 룰 5종 등재
2026-08-04 09:05:00 +09:00

2039 lines
73 KiB
TypeScript

/**
* NOTE: @since versions (engine-v1.x.x) refer to internal template engine
* development iterations, not G7 platform release versions.
*/
/**
* 그누보드7 템플릿 엔진 - 데이터 소스 매니저
*
* data_sources를 처리하고 API 요청을 관리하는 매니저
*
* @package G7
* @subpackage Template Engine
* @since 1.0.0
*/
import { getApiClient } from '../api/ApiClient';
import { AuthManager } from '../auth/AuthManager';
import { resolveExpressionString, evaluateRenderCondition } from './helpers/RenderHelpers';
import { DataBindingEngine } from './DataBindingEngine';
import { extractSingleBinding } from './BindingShape';
import { hasPipes } from './PipeRegistry';
import { getErrorHandlingResolver } from '../error';
import { getActionDispatcher } from './ActionDispatcher';
import type { ErrorHandlingMap, OnErrorHandler, OnSuccessHandler, ErrorContext, SuccessContext, ErrorCondition } from '../types/ErrorHandling';
import { createLogger } from '../utils/Logger';
import { webSocketManager } from '../websocket/WebSocketManager';
import type { G7DevToolsInterface } from './G7CoreGlobals';
import type { GlobalHeaderRule } from './LayoutLoader';
const logger = createLogger('DataSourceManager');
// DataBindingEngine 인스턴스 (표현식 평가용)
const bindingEngine = new DataBindingEngine();
/**
* 파라미터 값을 해석합니다. 배열 등 원본 타입을 유지합니다.
*
* resolveExpressionString은 항상 문자열을 반환하므로,
* 전체가 {{...}} 표현식인 경우 evaluateExpression을 사용하여
* 배열, 객체 등 원본 타입을 유지합니다.
*
* @param value 파라미터 값 (문자열)
* @param context 바인딩 컨텍스트
* @returns 해석된 값 (원본 타입 유지)
*/
function resolveParamValue(value: string, context: Record<string, any>): any {
// 전체가 하나의 {{...}} 표현식인지 확인
// 예: "{{query['sales_status[]']}}" → true
// 예: "page={{query.page}}" → false
//
// 판정은 BindingShape 정본을 쓴다. 종전 greedy 정규식 `^\{\{(.+)\}\}$` 은
// `"{{a}}-{{b}}"` 같은 보간 문자열도 단일 표현식으로 오판했고, 그때 잘라낸
// `a}}-{{b` 를 평가하다 예외가 나면 **원본 문자열이 그대로 서버로 전송**됐다.
// @since engine-v1.55.0
const expression = extractSingleBinding(value);
if (expression !== null) {
// 단일 표현식인 경우 evaluateExpression으로 원본 타입 유지
try {
// 파이프 표현식은 evaluatePipeExpression 으로 평가한다 — evaluateExpression 은
// `|` 를 JS 비트 OR 로 보므로 인자 있는 파이프는 예외로 아래 catch 에 걸려
// 원본 `{{...}}` 리터럴이 그대로 쿼리스트링에 실려 서버로 전송된다.
// @since engine-v1.54.10
return hasPipes(expression)
? bindingEngine.evaluatePipeExpression(expression, context, { skipCache: true })
: bindingEngine.evaluateExpression(expression, context);
} catch (error) {
logger.error('Failed to evaluate param expression:', expression, error);
return value; // 실패 시 원본 반환
}
}
// 복합 문자열인 경우 resolveExpressionString 사용 (문자열 보간)
return resolveExpressionString(value, context, { skipCache: true });
}
/**
* URLSearchParams를 배열 파라미터를 지원하는 객체로 변환
*
* URLSearchParams.entries()와 Object.fromEntries()를 사용하면
* 중복 키(배열 파라미터)의 마지막 값만 남게 됩니다.
* 이 함수는 getAll()을 사용하여 모든 값을 배열로 수집합니다.
*
* @param params URLSearchParams 인스턴스
* @returns 배열 파라미터를 지원하는 객체
*
* @example
* // sales_status[]=a&sales_status[]=b → { 'sales_status[]': ['a', 'b'] }
* // page=1&sort=name → { page: '1', sort: 'name' }
*/
function parseQueryParams(params: URLSearchParams): Record<string, string | string[]> {
const result: Record<string, string | string[]> = {};
for (const key of params.keys()) {
// 이미 처리한 키는 건너뛰기
if (key in result) continue;
const values = params.getAll(key);
if (values.length > 1) {
// 여러 값이 있으면 배열로 저장
result[key] = values;
} else if (values.length === 1) {
// 단일 값이지만 key[] 형태면 배열로 저장 (일관성)
if (key.endsWith('[]')) {
result[key] = values;
} else {
result[key] = values[0];
}
}
}
return result;
}
/**
* G7Core.devTools 인터페이스 가져오기
*
* G7DevToolsCore.getInstance() 직접 호출 대신 G7Core.devTools를 사용합니다.
* DevTools가 비활성화되거나 초기화되지 않은 경우 안전하게 undefined 반환
*/
function getDevTools(): G7DevToolsInterface | undefined {
try {
const G7Core = (window as any).G7Core;
return G7Core?.devTools;
} catch {
return undefined;
}
}
/**
* 데이터 소스 타입
*/
export type DataSourceType = 'api' | 'static' | 'route_params' | 'query_params' | 'websocket';
/**
* WebSocket 채널 타입
*/
export type WebSocketChannelType = 'public' | 'private' | 'presence';
/**
* HTTP 메서드 타입
*/
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
/**
* 로딩 전략 타입
*
* - blocking: API 응답 완료 전까지 렌더링하지 않음
* - progressive: 스켈레톤 UI 렌더링 후 데이터 로드되면 업데이트 (기본값)
* - background: 일단 렌더링하고 백그라운드에서 fetch
*/
export type LoadingStrategy = 'blocking' | 'progressive' | 'background';
/**
* 데이터 소스 로딩 상태
*/
export type DataSourceLoadingState = 'idle' | 'loading' | 'success' | 'error';
/**
* 데이터 소스 인터페이스
*/
export interface DataSource {
/**
* 데이터 소스 고유 ID
*/
id: string;
/**
* 데이터 소스 타입
*/
type: DataSourceType;
/**
* API 엔드포인트 (type이 'api'인 경우)
*/
endpoint?: string;
/**
* HTTP 메서드 (type이 'api'인 경우)
*/
method?: HttpMethod;
/**
* 자동 fetch 여부
*/
auto_fetch?: boolean;
/**
* 인증 필요 여부 (deprecated: auth_mode 사용 권장)
*
* @deprecated auth_mode를 사용하세요. auth_required: true는 auth_mode: 'required'와 동일합니다.
*/
auth_required?: boolean;
/**
* 인증 모드
*
* - 'required': 토큰 필수 (ApiClient 사용, 토큰 없으면 요청 실패)
* - 'optional': 토큰 있으면 전송, 없으면 일반 요청 (비회원/회원 공용 API)
* - 'none': 토큰 안 보냄 (공개 API, 기본값)
*
* auth_required: true는 auth_mode: 'required'와 동일하게 동작합니다.
*/
auth_mode?: 'required' | 'optional' | 'none';
/**
* 로딩 전략 (기본값: progressive)
*/
loading_strategy?: LoadingStrategy;
/**
* 요청 Content-Type (기본값: 'application/json')
*
* 'multipart/form-data'로 설정하면 파일 업로드가 가능합니다.
* File/Blob 객체를 params에 포함하여 FormData로 전송합니다.
*
* @example
* { "id": "upload", "endpoint": "/api/upload", "method": "POST",
* "contentType": "multipart/form-data", "params": { "file": "{{_global.uploadFile}}" } }
*/
contentType?: string;
/**
* 요청 파라미터
*/
params?: Record<string, any>;
/**
* 정적 데이터 (type이 'static'인 경우)
*/
data?: any;
/**
* 로컬 상태 초기화 설정
*
* API 응답 데이터를 _local[key]에 자동 복사합니다.
* 폼 편집 시 초기 데이터를 로컬 상태로 관리할 때 사용합니다.
*
* 문자열 형태: 전체 응답 데이터를 저장
* 객체 형태: path를 지정하여 응답의 특정 필드만 저장
*
* @example
* // 문자열 형태 - 전체 응답 저장
* { "id": "user", "endpoint": "/api/users/1", "initLocal": "form" }
* // → response.data가 _local.form에 복사됨
*
* @example
* // 객체 형태 - 특정 필드만 저장
* { "id": "user", "endpoint": "/api/users/1", "initLocal": { "key": "form", "path": "data" } }
* // → response.data.data가 _local.form에 복사됨
*
* @example
* // 맵 형태 (engine-v1.7.0+) - 여러 필드를 각각 저장 (표현식 지원)
* { "id": "cart", "endpoint": "/api/cart", "initLocal": { "selectedItems": "data.item_ids" } }
* // → response.data.data.item_ids가 _local.selectedItems에 복사됨
*
* @example
* // 맵 형태 + 표현식 (engine-v1.7.0+)
* { "id": "cart", "initLocal": { "selectedItems": "{{data.items.map(i => i.id)}}" } }
* // → response.data.data.items를 map하여 _local.selectedItems에 저장
*
* @example
* // dot notation 타겟 경로 (engine-v1.8.0+)
* { "id": "checkout", "initLocal": { "checkout.item_coupons": "data.promotions.item_coupons" } }
* // → response.data.data.promotions.item_coupons가 _local.checkout.item_coupons에 복사됨
*
* @example
* // 중첩 객체 표기법 (engine-v1.8.0+)
* { "id": "checkout", "initLocal": {
* "checkout": {
* "item_coupons": "data.promotions.item_coupons",
* "use_points": "data.use_points"
* }
* }
* }
*
* @example
* // 병합 전략 옵션 (engine-v1.8.0+) - _merge: "deep" | "shallow" | "replace"
* { "id": "checkout", "initLocal": {
* "_merge": "replace",
* "checkout.item_coupons": "data.promotions.item_coupons"
* }
* }
*/
initLocal?: string | { key: string; path: string } | Record<string, string | Record<string, string>>;
/**
* 전역 상태 초기화 설정
*
* API 응답 데이터를 _global[key]에 자동 복사합니다.
* 여러 페이지에서 공유하는 데이터(현재 사용자, 앱 설정 등)에 사용합니다.
*
* 문자열 형태: 전체 응답 데이터를 저장
* 객체 형태: path를 지정하여 응답의 특정 필드만 저장
*
* @example
* // 문자열 형태 - 전체 응답 저장
* { "id": "currentUser", "endpoint": "/api/me", "initGlobal": "currentUser" }
* // → response.data가 _global.currentUser에 복사됨
*
* @example
* // 객체 형태 - 특정 필드만 저장
* { "id": "modules", "endpoint": "/api/modules", "initGlobal": { "key": "installedModules", "path": "data" } }
* // → response.data.data가 _global.installedModules에 복사됨
*
* @example
* // 배열 형태 - 여러 전역 상태 동시 초기화
* { "id": "layout_files", "endpoint": "/api/layouts", "initGlobal": [
* "layoutFilesList",
* { "key": "selectedLayoutName", "path": "[0].name" }
* ] }
* // → response.data가 _global.layoutFilesList에 복사됨
* // → response.data[0].name이 _global.selectedLayoutName에 복사됨
*
* @example
* // 맵 형태 (engine-v1.7.0+) - 여러 필드를 각각 저장 (표현식 지원)
* { "id": "config", "endpoint": "/api/config", "initGlobal": { "theme": "data.theme", "locale": "data.locale" } }
* // → response.data.data.theme이 _global.theme에, response.data.data.locale이 _global.locale에 복사됨
*/
initGlobal?: string | { key: string; path: string } | Array<string | { key: string; path: string }> | Record<string, string>;
/**
* 격리된 상태 초기화 설정
*
* API 응답 데이터를 _isolated[key]에 자동 복사합니다.
* isolatedState 속성이 정의된 컴포넌트 내에서만 사용됩니다.
* 해당 영역 내에서만 유효한 독립적인 상태가 필요할 때 사용합니다.
*
* 문자열 형태: 전체 응답 데이터를 저장
* 객체 형태: { key: 저장할 키, path: 응답에서 추출할 경로 }
*
* @example
* // 문자열 형태 - 전체 응답 저장
* { "id": "categories", "endpoint": "/api/categories", "initIsolated": "categoryList" }
* // → response.data가 _isolated.categoryList에 복사됨
*
* @example
* // 객체 형태 - 특정 필드만 저장
* { "id": "options", "endpoint": "/api/options", "initIsolated": { "key": "items", "path": "data.list" } }
* // → response.data.data.list가 _isolated.items에 복사됨
*
* @example
* // 맵 형태 (engine-v1.7.0+) - 여러 필드를 각각 저장 (표현식 지원)
* { "id": "widget", "endpoint": "/api/widget", "initIsolated": { "items": "data.list", "total": "data.count" } }
* // → response.data.data.list가 _isolated.items에, data.count가 _isolated.total에 복사됨
*/
initIsolated?: string | { key: string; path: string } | Record<string, string>;
/**
* 마운트 시 다시 fetch 여부
*
* true로 설정하면 페이지에 진입할 때마다 데이터를 다시 fetch하고
* initLocal/initGlobal 옵션이 있는 경우 로컬/전역 상태를 강제로 재초기화합니다.
*
* SPA에서 같은 페이지로 다시 이동할 때 (예: 목록 → 편집 → 목록 → 편집)
* 이전에 수정한 폼 데이터가 남아있는 문제를 해결합니다.
*
* @example
* // data_sources에서 사용
* { "id": "board", "endpoint": "/api/boards/{{route.id}}", "initLocal": "formData", "refetchOnMount": true }
* // → 페이지 진입 시 항상 board 데이터를 다시 fetch하고 _local.formData를 초기화
*
* @default false
*/
refetchOnMount?: boolean;
/**
* 커스텀 HTTP 헤더
*
* API 요청 시 추가할 커스텀 헤더를 정의합니다.
* 값에 표현식({{...}})을 사용하여 동적으로 헤더 값을 설정할 수 있습니다.
*
* 지원되는 컨텍스트:
* - route: 라우트 파라미터 (예: {{route.id}})
* - query: 쿼리 파라미터 (예: {{query.mode}})
* - _global: 전역 상태 (예: {{_global.cartKey}})
*
* 주의:
* - _local은 렌더링 전에 data_source가 처리되므로 사용할 수 없습니다.
* - 값이 null, undefined, 빈 문자열인 헤더는 전송되지 않습니다.
*
* @example
* // 장바구니 키 헤더 전송
* {
* "id": "cart",
* "endpoint": "/api/cart",
* "headers": {
* "X-Cart-Key": "{{_global.cartKey}}"
* }
* }
*
* @example
* // 여러 커스텀 헤더
* {
* "id": "data",
* "endpoint": "/api/data",
* "headers": {
* "X-Custom-Header": "custom-value",
* "X-User-Id": "{{_global.currentUser?.uuid}}"
* }
* }
*/
headers?: Record<string, string>;
/**
* 조건부 로딩 표현식
*
* 표현식이 truthy로 평가될 때만 이 데이터 소스를 fetch합니다.
* 같은 id를 가진 여러 데이터 소스를 정의하고, 조건에 따라 하나만 실행되도록 할 수 있습니다.
*
* 지원되는 컨텍스트:
* - route: 라우트 파라미터 (예: {{route.id}})
* - query: 쿼리 파라미터 (예: {{query.mode}})
* - _global: 전역 상태 (예: {{_global.isAdmin}})
*
* 주의: _local은 렌더링 전에 data_source가 처리되므로 사용할 수 없습니다.
*
* @example
* // 수정 모드: route.id가 있을 때만 기존 데이터 로드
* { "id": "user", "endpoint": "/api/users/{{route.id}}", "if": "{{route.id}}" }
*
* // 생성 모드: route.id가 없을 때만 템플릿 로드
* { "id": "user", "endpoint": "/api/users/template", "if": "{{!route.id}}" }
*/
if?: string;
/**
* 조건부 데이터 소스 로드 조건 (복합 조건)
*
* if 속성의 상위 호환으로 AND/OR 그룹 및 if-else 체인을 지원합니다.
*
* 주의:
* - if와 conditions가 둘 다 있으면 if가 우선 평가됩니다
* - _local은 렌더링 전에 data_source가 처리되므로 사용할 수 없습니다
*
* @example
* // AND 그룹: route.id가 있고 권한이 있을 때만 로드
* {
* "id": "product",
* "endpoint": "/api/products/{{route.id}}",
* "conditions": {
* "and": ["{{!!route.id}}", "{{_global.hasPermission('view_product')}}"]
* }
* }
*
* @example
* // OR 그룹: admin이거나 manager일 때 로드
* {
* "id": "dashboard",
* "endpoint": "/api/admin/dashboard",
* "conditions": {
* "or": ["{{_global.user?.role === 'admin'}}", "{{_global.user?.role === 'manager'}}"]
* }
* }
*
* @since engine-v1.10.0
*/
conditions?: import('./helpers/ConditionEvaluator').ConditionsProperty;
/**
* WebSocket 채널명 (type이 'websocket'인 경우)
*
* @example "admin.dashboard"
*/
channel?: string;
/**
* WebSocket 이벤트명 (type이 'websocket'인 경우)
*
* @example "dashboard.stats.updated"
*/
event?: string;
/**
* WebSocket 채널 타입 (기본값: 'private')
*/
channel_type?: WebSocketChannelType;
/**
* WebSocket 수신 시 업데이트할 대상 데이터 소스 ID
*
* 이 필드가 설정되면 WebSocket 이벤트 수신 시
* 해당 ID의 데이터 소스 캐시를 업데이트합니다.
*
* @example "dashboard_stats"
*/
target_source?: string;
/**
* WebSocket 이벤트 수신 시 실행할 액션 목록 (engine-v1.33.0+)
*
* WebSocket 이벤트 수신 시 각 액션이 순차적으로 실행됩니다.
* 액션의 `$args[0]`로 수신 페이로드에 접근할 수 있습니다.
*
* 사용 예시: 수신 시 관련 데이터소스 refetch + 토스트 알림 표시
*
* @example
* [
* { "handler": "refetchDataSource", "params": { "dataSourceId": "notifications" } },
* { "handler": "toast", "params": { "type": "info", "message": "{{$args[0].subject}}" } }
* ]
*/
onReceive?: any[];
/**
* API 호출 실패 시 사용할 기본 데이터
*
* API 요청이 실패하거나 endpoint가 존재하지 않을 때
* 이 값이 데이터로 사용됩니다.
* errorHandling이 함께 정의된 경우 에러 핸들러가 먼저 실행된 후
* fallback 데이터가 렌더링에 사용됩니다.
*
* @example
* ```json
* // 객체 fallback
* { "fallback": { "users": 0, "posts": 0 } }
*
* // 배열 fallback
* { "fallback": [] }
*
* // 단순 값 fallback
* { "fallback": 0 }
* ```
*/
fallback?: any;
/**
* HTTP 에러 코드별 핸들러 매핑
*
* 이 데이터 소스의 API 호출 실패 시 에러 코드에 따라 다른 핸들러를 실행합니다.
* onError보다 우선순위가 높습니다.
*
* @example
* ```json
* {
* "errorHandling": {
* "403": { "handler": "showErrorPage", "params": { "target": "content" } },
* "404": { "handler": "toast", "params": { "type": "warning", "message": "$t:errors.not_found" } },
* "default": { "handler": "toast", "params": { "type": "error", "message": "{{error.message}}" } }
* }
* }
* ```
*/
errorHandling?: ErrorHandlingMap;
/**
* 응답 조건부 에러 처리
*
* API가 200으로 응답하더라도 응답 데이터 기반으로 에러를 트리거합니다.
* 조건이 truthy로 평가되면 errorHandling에 정의된 핸들러가 실행됩니다.
*
* 표현식에서 `response` 객체로 API 응답 데이터에 접근합니다.
*
* @since engine-v1.18.0
*
* @example
* ```json
* {
* "errorCondition": {
* "if": "{{response?.data?.abilities?.can_update !== true}}",
* "errorCode": 403
* },
* "errorHandling": {
* "403": { "handler": "showErrorPage", "params": { "target": "content" } }
* }
* }
* ```
*/
errorCondition?: ErrorCondition;
/**
* 범용 에러 핸들러 (폴백)
*
* errorHandling에 해당 에러 코드가 정의되지 않은 경우 실행됩니다.
* 단일 핸들러 또는 핸들러 배열을 지정할 수 있습니다.
*
* @example
* ```json
* // 단일 핸들러
* { "onError": { "handler": "toast", "params": { "type": "error", "message": "{{error.message}}" } } }
*
* // 핸들러 배열 (순차 실행)
* { "onError": [
* { "handler": "setState", "params": { "target": "global", "errorMessage": "{{error.message}}" } },
* { "handler": "openModal", "target": "error_modal" }
* ] }
* ```
*/
onError?: OnErrorHandler;
/**
* 데이터 로드 성공 시 실행할 핸들러
*
* API 요청이 성공하고 데이터가 캐시에 저장된 후 실행됩니다.
* params에서 {{response.xxx}} 형태로 API 응답에 접근할 수 있습니다.
*
* 주요 사용 사례:
* - 데이터 로드 후 조건부 모달 표시
* - 데이터 기반 추가 상태 설정
* - 로드 완료 후 UI 업데이트
*
* @since engine-v1.17.0
*
* @example
* ```json
* // 단일 핸들러 - 조건부 모달 표시
* {
* "onSuccess": {
* "handler": "conditions",
* "conditions": [
* {
* "if": "{{response.data.unavailable_items?.length > 0}}",
* "handler": "openModal",
* "target": "unavailable_modal"
* }
* ]
* }
* }
*
* // 핸들러 배열 (순차 실행)
* { "onSuccess": [
* { "handler": "setState", "params": { "target": "local", "loaded": true } },
* { "handler": "toast", "params": { "type": "success", "message": "Data loaded" } }
* ] }
*
* // sequence로 여러 작업 수행
* {
* "onSuccess": {
* "handler": "sequence",
* "actions": [
* { "handler": "setState", "params": { "target": "local", "resultData": "{{response.data}}" } },
* { "handler": "apiCall", "params": { "endpoint": "/api/log", "method": "POST" } }
* ]
* }
* }
* ```
*/
onSuccess?: OnSuccessHandler;
/**
* 출처 메타 (편집 모드 전용 — engine-v1.50.0+)
*
* 레이아웃 편집기가 `with_source_meta=1` 로 요청할 때만 백엔드가 부여하는
* 소유 확장 식별 메타입니다. 동일 `id` 를 가진 data_source 가 여러 출처
* (템플릿/모듈/플러그인)에 정의되어 충돌할 때, 샘플 데이터 해소
* (`sampleDataProvider`)가 자기 출처의 샘플을 우선 선택하도록 합니다.
*
* 일반 렌더 경로에는 부여되지 않으며 저장 시 sanitize 에서 제외되므로
* 영속 레이아웃 JSON 에는 포함되지 않습니다.
*
* @since engine-v1.50.0
*/
__source?: {
kind: 'template' | 'module' | 'plugin' | 'core' | 'base' | 'route';
identifier?: string | null;
};
}
/**
* 데이터 소스 결과 인터페이스
*/
export interface DataSourceResult {
/**
* 데이터 소스 ID
*/
id: string;
/**
* 로딩 상태
*/
state: DataSourceLoadingState;
/**
* 데이터 (성공 시)
*/
data?: any;
/**
* 에러 (실패 시)
*/
error?: Error;
/**
* HTTP 에러 코드 (실패 시)
*/
errorCode?: number;
/**
* 에러가 핸들러에 의해 처리되었는지 여부
*/
errorHandled?: boolean;
}
/**
* 데이터 소스 매니저 옵션
*/
export interface DataSourceManagerOptions {
/**
* 401 응답 시 호출될 콜백
*/
onUnauthorized?: () => void;
/**
* 에러 발생 시 호출될 콜백
*/
onError?: (error: Error, source: DataSource) => void;
/**
* 샘플 데이터 프로바이더 (편집 모드 전용)
*
* 옵션이 주입되면 `fetchApiDataSource` 가 네트워크 fetch 대신 프로바이더의
* `resolve()` 결과를 반환한다. 일반 렌더 모드(편집기 외) 부팅에서는 옵션을
* 전달하지 않으므로 기존 동작 100% 보존. 계획서 참조.
*
* @since engine-v1.50.0
*/
sampleProvider?: SampleDataProvider;
/**
* onSuccess 핸들러 실행에 사용할 ActionDispatcher (편집 모드 전용)
*
* 옵션이 주입되면 `executeOnSuccessHandler` 가 전역 `getActionDispatcher()` 대신
* 본 dispatcher 를 사용한다. 일반 렌더 모드는 옵션 미전달 → 기존 동작 보존.
*
* 동기: PreviewCanvas 가 자체 DataSourceManager 인스턴스를 만들지만, 그 인스턴스
* 의 onSuccess 핸들러가 호스트 ActionDispatcher 를 호출하면 호스트 `setGlobalState`
* 가 발화되어 호스트 `#app` 이 통째로 unmount 되는 결함이 발생한다.
*
* @since engine-v1.50.0
*/
actionDispatcher?: import('./ActionDispatcher').ActionDispatcher;
}
/**
* 샘플 데이터 프로바이더 인터페이스 — 편집 모드 캔버스 전용
*
* `useSampleData` 가 병합 `editorSpec.sampleData`(byDataSourceId / bySource /
* byEndpointPattern) 로 구성한 뒤 편집 모드 부팅 시 `DataSourceManager` 옵션으로
* 주입한다. 미매칭 데이터소스는 도메인-중립 빈 응답으로 디그레이드한다
* (코어 키워드 프리셋 폐기 —-v1.50.0).
*
* @since engine-v1.50.0
*/
export interface SampleDataProvider {
/**
* 데이터소스 ID 가 샘플 모드의 매칭 후보인지 확인.
* `false` 면 일반 fetch 경로(네트워크) 로 폴백.
*/
has(dataSourceId: string): boolean;
/**
* 데이터소스에 대해 샘플 데이터를 동기/비동기로 반환.
* 반환값은 일반 API 응답과 같은 shape 이어야 한다(`{ data: [...] }` 등).
*/
resolve(dataSource: DataSource): unknown | Promise<unknown>;
}
/**
* 조건 평가용 컨텍스트 인터페이스
*/
export interface ConditionContext {
/**
* 라우트 파라미터
*/
route?: Record<string, string>;
/**
* 쿼리 파라미터
*/
query?: Record<string, string>;
/**
* 전역 상태
*/
_global?: Record<string, any>;
}
/**
* 데이터 캐시 엔트리 인터페이스
*/
interface DataCacheEntry {
/**
* 캐시된 데이터
*/
data: any;
/**
* 캐시 생성 시간
*/
timestamp: number;
}
/**
* 데이터 소스 매니저 클래스
*
* data_sources를 처리하고 API 요청을 관리
*/
export class DataSourceManager {
/**
* 데이터 캐시 만료 시간 (밀리초)
*
* 5분으로 설정하여 메모리 누수를 방지합니다.
*/
private static readonly DATA_CACHE_TTL = 300000; // 5분
private options: DataSourceManagerOptions;
private dataCache: Map<string, DataCacheEntry> = new Map();
private bindingEngine: DataBindingEngine;
/**
* 전역 헤더 규칙 (레이아웃에서 설정)
*
* API 호출 시 패턴에 매칭되는 엔드포인트에 자동으로 헤더를 추가합니다.
*
* @since engine-v1.16.0
*/
private globalHeaders: GlobalHeaderRule[] = [];
constructor(options: DataSourceManagerOptions = {}) {
this.options = options;
this.bindingEngine = new DataBindingEngine();
}
/**
* 전역 헤더 규칙을 설정합니다.
*
* 레이아웃 로드 시 호출되어 globalHeaders를 설정합니다.
* 이후 모든 API 호출에서 패턴 매칭을 통해 자동으로 헤더가 적용됩니다.
*
* @param headers 전역 헤더 규칙 배열
* @since engine-v1.16.0
*/
public setGlobalHeaders(headers: GlobalHeaderRule[]): void {
this.globalHeaders = headers || [];
}
/**
* 엔드포인트가 패턴에 매칭되는지 확인합니다.
*
* @param endpoint API 엔드포인트 경로
* @param pattern glob 스타일 패턴 ("*", "/api/shop/*" 등)
* @returns 매칭 여부
* @since engine-v1.16.0
*/
private matchesPattern(endpoint: string, pattern: string): boolean {
if (pattern === '*') return true;
// glob 패턴을 정규식으로 변환 (* → .*)
const regexPattern = pattern
.replace(/[.+?^${}()|[\]\\]/g, '\\$&') // 특수문자 이스케이프
.replace(/\*/g, '.*'); // * → .*
return new RegExp(`^${regexPattern}$`).test(endpoint);
}
/**
* 엔드포인트에 매칭되는 전역 헤더를 추출합니다.
*
* globalHeaders 배열을 순회하며 패턴이 매칭되는 모든 헤더를 병합합니다.
* 나중에 정의된 규칙의 헤더가 먼저 정의된 규칙의 헤더를 덮어씁니다.
*
* @param endpoint API 엔드포인트 경로
* @param context 표현식 평가를 위한 컨텍스트
* @returns 병합된 헤더 객체
* @since engine-v1.16.0
*/
/**
* params 객체를 FormData로 변환합니다.
* File/Blob은 그대로 append, 기타 값은 문자열 변환하여 append합니다.
*/
private toFormData(params: Record<string, any>): FormData {
const formData = new FormData();
for (const [key, value] of Object.entries(params)) {
if (value instanceof File || value instanceof Blob) {
formData.append(key, value);
} else if (value !== null && value !== undefined) {
formData.append(
key,
typeof value === 'object' ? JSON.stringify(value) : String(value)
);
}
}
return formData;
}
private getMatchingGlobalHeaders(
endpoint: string,
context: Record<string, any>,
): Record<string, string> {
const result: Record<string, string> = {};
for (const rule of this.globalHeaders) {
if (this.matchesPattern(endpoint, rule.pattern)) {
Object.entries(rule.headers).forEach(([key, value]) => {
// 표현식 평가 ({{_global.xxx}} 등)
const resolved = resolveParamValue(value, context);
// null, undefined, 빈 문자열이 아닌 경우에만 헤더 추가
if (resolved != null && resolved !== '') {
result[key] = String(resolved);
}
});
}
}
return result;
}
/**
* 조건부 데이터 소스 필터링
*
* 같은 id를 가진 데이터 소스 중 조건을 만족하는 첫 번째 것만 선택합니다.
* - if 속성이 없는 데이터 소스는 항상 선택됩니다.
* - if 속성이 있는 데이터 소스는 조건이 truthy일 때만 선택됩니다.
* - 같은 id가 여러 개인 경우, 조건을 만족하는 첫 번째 것만 선택됩니다.
*
* @param sources 데이터 소스 배열
* @param conditionContext 조건 평가용 컨텍스트 (route, query, _global)
* @returns 필터링된 데이터 소스 배열
*/
filterByCondition(
sources: DataSource[],
conditionContext: ConditionContext = {}
): DataSource[] {
// 이미 선택된 id를 추적
const selectedIds = new Set<string>();
const filteredSources: DataSource[] = [];
for (const source of sources) {
// 이미 같은 id가 선택되었으면 스킵
if (selectedIds.has(source.id)) {
continue;
}
// if와 conditions가 모두 없으면 항상 선택
if (!source.if && !source.conditions) {
selectedIds.add(source.id);
filteredSources.push(source);
continue;
}
// 조건 평가 (evaluateRenderCondition은 if와 conditions 모두 지원)
const shouldInclude = evaluateRenderCondition(
{ if: source.if, conditions: source.conditions },
conditionContext,
this.bindingEngine,
`data_source:${source.id}`
);
if (shouldInclude) {
selectedIds.add(source.id);
filteredSources.push(source);
}
}
return filteredSources;
}
/**
* errorCondition을 평가하여 응답 데이터 기반 에러 조건을 체크합니다.
*
* @param source 데이터 소스
* @param responseData API 응답 데이터
* @returns errorCondition이 truthy면 해당 errorCode, 아니면 null
*/
private checkErrorCondition(source: DataSource, responseData: any): number | null {
if (!source.errorCondition?.if || source.errorCondition?.errorCode === undefined) {
return null;
}
try {
const context = { response: responseData };
const result = evaluateRenderCondition(
{ if: source.errorCondition.if },
context,
this.bindingEngine,
`errorCondition:${source.id}`
);
if (result) {
logger.log(`errorCondition matched for ${source.id}, triggering error ${source.errorCondition.errorCode}`);
return source.errorCondition.errorCode;
}
} catch (error) {
logger.error(`Failed to evaluate errorCondition for ${source.id}:`, error);
}
return null;
}
/**
* 데이터 소스 fetch (blocking + progressive 전용)
*
* @param sources 데이터 소스 배열
* @param routeParams 라우트 파라미터
* @param queryParams 쿼리 파라미터
* @param globalState _global 상태 (globalHeaders 표현식 평가용)
* @param localState _local 상태 (globalHeaders 표현식 평가용)
* @returns 데이터 맵 (key: 데이터 소스 ID, value: 데이터)
*/
async fetchDataSources(
sources: DataSource[],
routeParams: Record<string, string> = {},
queryParams: URLSearchParams = new URLSearchParams(),
globalState?: Record<string, any>,
localState?: Record<string, any>,
): Promise<Record<string, any>> {
const results: Record<string, any> = {};
// auto_fetch가 true인 데이터 소스만 처리 (websocket 타입 제외)
//
// 샘플 모드(편집기) 예외: auto_fetch:false 인 지연 소스(탭/모달 활성 시 런타임에
// fetch)는 정적 시뮬레이션인 편집기 캔버스에서 활성화 액션을 실행할 수 없어, 페이지
// 상태로 게이트를 열어도 데이터가 비어 본체가 미렌더된다(myComments
// 내댓글 서브탭). 샘플 모드에서는 지연 소스도 샘플 주입 대상에 포함해 게이트 노출 시
// 본체가 렌더되게 한다(런타임 동작과 정합 — 런타임은 탭 활성 시 동일 데이터를 fetch).
// 일반 렌더 모드(sampleProvider 미주입)는 기존 auto_fetch 필터를 100% 보존한다.
const inSampleMode = !!this.options.sampleProvider;
const autoFetchSources = sources.filter(
(source) =>
(inSampleMode || source.auto_fetch !== false) && source.type !== 'websocket'
);
// 병렬 처리
await Promise.all(
autoFetchSources.map(async (source) => {
try {
const data = await this.fetchDataSource(source, routeParams, queryParams, globalState, localState);
results[source.id] = data;
this.dataCache.set(source.id, { data, timestamp: Date.now() });
// errorCondition 체크: 200 응답이어도 조건 만족 시 에러로 처리
const errorConditionCode = this.checkErrorCondition(source, data);
if (errorConditionCode !== null) {
const errorContext: ErrorContext = {
status: errorConditionCode,
message: `Error condition matched for ${source.id}`,
data: data,
};
const resolver = getErrorHandlingResolver();
const resolveResult = resolver.resolve(errorConditionCode, {
errorHandling: source.errorHandling,
onError: source.onError,
});
if (resolveResult.handler) {
try {
await resolver.execute(resolveResult.handler, errorContext);
} catch (handlerError) {
logger.error(`Error executing errorCondition handler for ${source.id}:`, handlerError);
}
}
// fallback이 정의된 경우 렌더링용 데이터로 사용
if (source.fallback !== undefined) {
results[source.id] = source.fallback;
this.dataCache.set(source.id, { data: source.fallback, timestamp: Date.now() });
logger.log(`Using fallback data for ${source.id} (errorCondition)`);
}
return; // onSuccess 스킵
}
// onSuccess 핸들러 실행
if (source.onSuccess) {
try {
const successContext: SuccessContext = {
data,
sourceId: source.id,
};
await this.executeOnSuccessHandler(source.onSuccess, successContext);
} catch (handlerError) {
logger.error(`Error executing onSuccess handler for ${source.id}:`, handlerError);
}
}
} catch (error) {
logger.error(`Failed to fetch data source: ${source.id}`, error);
// HTTP 에러 코드 추출
const errorCode = (error as any)?.response?.status ||
(error as any)?.status ||
500;
// 에러 컨텍스트 생성
// API 응답의 message를 우선 사용하고, 없으면 Axios 에러 메시지 사용
const responseData = (error as any)?.response?.data;
const errorMessage = responseData?.message || (error as Error).message || 'Unknown error';
const errorContext: ErrorContext = {
status: errorCode,
message: errorMessage,
errors: responseData?.errors,
data: responseData,
statusText: (error as any)?.response?.statusText,
};
// ErrorHandlingResolver를 통해 핸들러 결정 및 실행
const resolver = getErrorHandlingResolver();
const resolveResult = resolver.resolve(errorCode, {
errorHandling: source.errorHandling,
onError: source.onError,
});
if (resolveResult.handler) {
try {
await resolver.execute(resolveResult.handler, errorContext);
} catch (handlerError) {
logger.error(`Error executing error handler for ${source.id}:`, handlerError);
}
}
// fallback이 정의된 경우 렌더링용 데이터로 사용
if (source.fallback !== undefined) {
results[source.id] = source.fallback;
this.dataCache.set(source.id, { data: source.fallback, timestamp: Date.now() });
logger.log(`Using fallback data for ${source.id}`);
}
// 기존 onError 콜백도 호출 (호환성 유지)
if (this.options.onError) {
this.options.onError(error as Error, source);
}
}
}),
);
return results;
}
/**
* 데이터 소스 fetch (with result tracking)
*
* @param sources 데이터 소스 배열
* @param routeParams 라우트 파라미터
* @param queryParams 쿼리 파라미터
* @param globalState _global 상태 (선택적, refetchDataSource 시 endpoint 표현식에서 _global 접근 지원)
* @param localState _local 상태 (선택적, refetchDataSource 시 params 표현식에서 _local 접근 지원)
* @returns 데이터 소스 결과 배열
* @param options fetch 옵션
* - ignoreAutoFetch: true면 auto_fetch: false인 소스도 강제 fetch (refetchDataSource 용)
*/
async fetchDataSourcesWithResults(
sources: DataSource[],
routeParams: Record<string, string> = {},
queryParams: URLSearchParams = new URLSearchParams(),
globalState?: Record<string, any>,
localState?: Record<string, any>,
options?: { ignoreAutoFetch?: boolean },
): Promise<DataSourceResult[]> {
// 계약: WebSocket 소스는 fetch 대상이 아님 — 호출자가 사전 필터링해야 함 (engine-v1.32.5)
// 내부 filter는 safety net으로 유지하되, 호출자가 필터를 누락하면 dev 모드에서 경고
// (이전에는 silent filter로 인해 호출자의 인덱스 기반 매핑이 조용히 어긋나는 버그 발생)
const hasWebSocket = sources.some((source) => source.type === 'websocket');
if (hasWebSocket) {
const wsIds = sources
.filter((source) => source.type === 'websocket')
.map((s) => s.id)
.join(', ');
logger.warn(
`fetchDataSourcesWithResults received WebSocket sources (caller should filter them out before calling): ${wsIds}. ` +
`WebSocket sources are event listeners, not data providers — they should not be passed to fetch.`
);
}
// ignoreAutoFetch 옵션이 true면 auto_fetch 필터 무시 (refetchDataSource에서 사용)
// 그렇지 않으면 auto_fetch가 true인 데이터 소스만 처리 (websocket 타입 제외 — safety net)
const targetSources = options?.ignoreAutoFetch
? sources.filter((source) => source.type !== 'websocket')
: sources.filter(
(source) => source.auto_fetch !== false && source.type !== 'websocket'
);
// 병렬 처리 (각 결과를 추적)
const results = await Promise.all(
targetSources.map(async (source): Promise<DataSourceResult> => {
try {
const data = await this.fetchDataSource(source, routeParams, queryParams, globalState, localState);
this.dataCache.set(source.id, { data, timestamp: Date.now() });
// errorCondition 체크: 200 응답이어도 조건 만족 시 에러로 처리
const errorConditionCode = this.checkErrorCondition(source, data);
if (errorConditionCode !== null) {
const errorContext: ErrorContext = {
status: errorConditionCode,
message: `Error condition matched for ${source.id}`,
data: data,
};
const resolver = getErrorHandlingResolver();
const resolveResult = resolver.resolve(errorConditionCode, {
errorHandling: source.errorHandling,
onError: source.onError,
});
let errorHandled = false;
if (resolveResult.handler) {
// blocking 데이터소스의 경우:
// 핸들러를 await하면 데드락 발생 (showErrorPage가 #main_content를 대기하지만,
// blocking이 끝나야 #main_content가 마운트됨)
// → 핸들러를 비동기로 실행하고 즉시 blocking 해제
if (source.loading_strategy === 'blocking') {
resolver.execute(resolveResult.handler, errorContext).catch((handlerError) => {
logger.error(`Error executing errorCondition handler for ${source.id}:`, handlerError);
});
errorHandled = true;
} else {
try {
await resolver.execute(resolveResult.handler, errorContext);
errorHandled = true;
} catch (handlerError) {
logger.error(`Error executing errorCondition handler for ${source.id}:`, handlerError);
}
}
}
// fallback이 정의된 경우 렌더링용 데이터로 사용
if (source.fallback !== undefined) {
this.dataCache.set(source.id, { data: source.fallback, timestamp: Date.now() });
logger.log(`Using fallback data for ${source.id} (errorCondition)`);
return {
id: source.id,
state: 'success',
data: source.fallback,
};
}
return {
id: source.id,
state: 'error',
error: new Error(`Error condition matched: ${errorConditionCode}`),
errorCode: errorConditionCode,
errorHandled,
};
}
// onSuccess 핸들러 실행
if (source.onSuccess) {
try {
const successContext: SuccessContext = {
data,
sourceId: source.id,
};
await this.executeOnSuccessHandler(source.onSuccess, successContext);
} catch (handlerError) {
logger.error(`Error executing onSuccess handler for ${source.id}:`, handlerError);
}
}
return {
id: source.id,
state: 'success',
data,
};
} catch (error) {
logger.error(`Failed to fetch data source: ${source.id}`, error);
// HTTP 에러 코드 추출
const errorCode = (error as any)?.response?.status ||
(error as any)?.status ||
500;
// 에러 컨텍스트 생성
// API 응답의 message를 우선 사용하고, 없으면 Axios 에러 메시지 사용
const responseData = (error as any)?.response?.data;
const errorMessage = responseData?.message || (error as Error).message || 'Unknown error';
const errorContext: ErrorContext = {
status: errorCode,
message: errorMessage,
errors: responseData?.errors,
data: responseData,
statusText: (error as any)?.response?.statusText,
};
// ErrorHandlingResolver를 통해 핸들러 결정
const resolver = getErrorHandlingResolver();
const resolveResult = resolver.resolve(errorCode, {
errorHandling: source.errorHandling,
onError: source.onError,
});
let errorHandled = false;
if (resolveResult.handler) {
// blocking 데이터소스의 경우:
// 핸들러를 await하면 데드락 발생 (showErrorPage가 #main_content를 대기하지만,
// blocking이 끝나야 #main_content가 마운트됨)
// → 핸들러를 비동기로 실행하고 즉시 blocking 해제
if (source.loading_strategy === 'blocking') {
resolver.execute(resolveResult.handler, errorContext).catch((handlerError) => {
logger.error(`Error executing error handler for ${source.id}:`, handlerError);
});
errorHandled = true;
} else {
try {
// 핸들러 실행
await resolver.execute(resolveResult.handler, errorContext);
errorHandled = true;
} catch (handlerError) {
logger.error(`Error executing error handler for ${source.id}:`, handlerError);
}
}
}
// fallback이 정의된 경우 렌더링용 데이터로 사용
if (source.fallback !== undefined) {
this.dataCache.set(source.id, { data: source.fallback, timestamp: Date.now() });
logger.log(`Using fallback data for ${source.id}`);
return {
id: source.id,
state: 'success',
data: source.fallback,
};
}
// 기존 onError 콜백도 호출 (호환성 유지)
if (this.options.onError) {
this.options.onError(error as Error, source);
}
return {
id: source.id,
state: 'error',
error: error as Error,
errorCode,
errorHandled,
};
}
}),
);
return results;
}
/**
* 백그라운드에서 데이터 소스 fetch (비동기, 결과 반환 안 함)
*
* @param sources 데이터 소스 배열
* @param routeParams 라우트 파라미터
* @param queryParams 쿼리 파라미터
* @param onUpdate 데이터 업데이트 콜백
*/
fetchDataSourcesInBackground(
sources: DataSource[],
routeParams: Record<string, string> = {},
queryParams: URLSearchParams = new URLSearchParams(),
onUpdate?: (sourceId: string, data: any) => void,
): void {
// auto_fetch가 true이고 loading_strategy가 background인 소스만 처리 (websocket 타입 제외)
const backgroundSources = sources.filter(
(source) => source.auto_fetch !== false && source.loading_strategy === 'background' && source.type !== 'websocket'
);
// 각 소스를 백그라운드에서 fetch (await 없이)
backgroundSources.forEach((source) => {
this.fetchDataSource(source, routeParams, queryParams)
.then(async (data) => {
this.dataCache.set(source.id, { data, timestamp: Date.now() });
// errorCondition 체크: 200 응답이어도 조건 만족 시 에러로 처리
const errorConditionCode = this.checkErrorCondition(source, data);
if (errorConditionCode !== null) {
const errorContext: ErrorContext = {
status: errorConditionCode,
message: `Error condition matched for ${source.id}`,
data: data,
};
const resolver = getErrorHandlingResolver();
const resolveResult = resolver.resolve(errorConditionCode, {
errorHandling: source.errorHandling,
onError: source.onError,
});
if (resolveResult.handler) {
try {
await resolver.execute(resolveResult.handler, errorContext);
} catch (handlerError) {
logger.error(`Error executing errorCondition handler for ${source.id}:`, handlerError);
}
}
// fallback이 정의된 경우 렌더링용 데이터로 사용
if (source.fallback !== undefined) {
this.dataCache.set(source.id, { data: source.fallback, timestamp: Date.now() });
logger.log(`Using fallback data for ${source.id} (errorCondition)`);
if (onUpdate) {
onUpdate(source.id, source.fallback);
}
}
return; // onSuccess 스킵
}
// onSuccess 핸들러 실행
if (source.onSuccess) {
try {
const successContext: SuccessContext = {
data,
sourceId: source.id,
};
await this.executeOnSuccessHandler(source.onSuccess, successContext);
} catch (handlerError) {
logger.error(`Error executing onSuccess handler for ${source.id}:`, handlerError);
}
}
if (onUpdate) {
onUpdate(source.id, data);
}
})
.catch(async (error) => {
logger.error(`Background fetch failed: ${source.id}`, error);
// HTTP 에러 코드 추출
const errorCode = (error as any)?.response?.status ||
(error as any)?.status ||
500;
// 에러 컨텍스트 생성
// API 응답의 message를 우선 사용하고, 없으면 Axios 에러 메시지 사용
const responseData = (error as any)?.response?.data;
const errorMessage = responseData?.message || (error as Error).message || 'Unknown error';
const errorContext: ErrorContext = {
status: errorCode,
message: errorMessage,
errors: responseData?.errors,
data: responseData,
statusText: (error as any)?.response?.statusText,
};
// ErrorHandlingResolver를 통해 핸들러 결정 및 실행
const resolver = getErrorHandlingResolver();
const resolveResult = resolver.resolve(errorCode, {
errorHandling: source.errorHandling,
onError: source.onError,
});
if (resolveResult.handler) {
try {
await resolver.execute(resolveResult.handler, errorContext);
} catch (handlerError) {
logger.error(`Error executing error handler for ${source.id}:`, handlerError);
}
}
// fallback이 정의된 경우 렌더링용 데이터로 사용
if (source.fallback !== undefined) {
this.dataCache.set(source.id, { data: source.fallback, timestamp: Date.now() });
logger.log(`Using fallback data for ${source.id}`);
if (onUpdate) {
onUpdate(source.id, source.fallback);
}
return;
}
// 기존 onError 콜백도 호출 (호환성 유지)
if (this.options.onError) {
this.options.onError(error as Error, source);
}
});
});
}
/**
* 단일 데이터 소스 fetch
*
* @param source 데이터 소스
* @param routeParams 라우트 파라미터
* @param queryParams 쿼리 파라미터
* @param globalState _global 상태 (선택적)
* @returns 데이터
*/
private async fetchDataSource(
source: DataSource,
routeParams: Record<string, string>,
queryParams: URLSearchParams,
globalState?: Record<string, any>,
localState?: Record<string, any>,
): Promise<any> {
switch (source.type) {
case 'api':
return this.fetchApiDataSource(source, routeParams, queryParams, globalState, localState);
case 'static':
return source.data;
case 'route_params':
return routeParams;
case 'query_params':
return parseQueryParams(queryParams);
default:
throw new Error(`Unknown data source type: ${source.type}`);
}
}
/**
* API 데이터 소스 fetch
*
* @param source 데이터 소스
* @param routeParams 라우트 파라미터
* @param queryParams 쿼리 파라미터
* @param globalState _global 상태 (선택적)
* @param localState _local 상태 (선택적, params 표현식에서 _local 접근 지원)
* @returns 데이터
*/
private async fetchApiDataSource(
source: DataSource,
routeParams: Record<string, string>,
queryParams: URLSearchParams,
globalState?: Record<string, any>,
localState?: Record<string, any>,
): Promise<any> {
// 샘플 모드 분기 — 편집기 부팅 시에만 옵션이 주입됨.
// 일반 렌더 모드는 옵션 미주입이므로 이 분기에 진입하지 않아 기존 fetch 경로 100% 보존.
if (this.options.sampleProvider?.has(source.id)) {
const sample = await Promise.resolve(this.options.sampleProvider.resolve(source));
return sample;
}
if (!source.endpoint) {
throw new Error(`API data source ${source.id} has no endpoint`);
}
// 표현식 평가용 컨텍스트 구성
// _global이 전달된 경우 endpoint 표현식에서 _global.xxx 접근 가능
// _local이 전달된 경우 params 표현식에서 _local.xxx 접근 가능
// parseQueryParams를 사용하여 배열 쿼리 파라미터 지원 (예: sales_status[]=a&sales_status[]=b)
const expressionContext: Record<string, any> = {
route: routeParams,
query: parseQueryParams(queryParams),
};
// globalState가 전달된 경우 _global로 추가
if (globalState) {
expressionContext._global = globalState;
}
// localState가 전달된 경우 _local로 추가
// sequence 내 setState local 후 refetchDataSource에서 {{_local.xxx}} 접근 지원
if (localState) {
expressionContext._local = localState;
}
// endpoint의 {{...}} 표현식 치환 (RenderHelpers의 범용 함수 사용)
// 복잡한 표현식({{route?.id}}, 삼항 연산자 등)도 지원
// IMPORTANT: skipCache: true - 매 요청마다 새로운 컨텍스트로 평가해야 함 (페이지네이션 등)
let endpoint = resolveExpressionString(source.endpoint, expressionContext, { skipCache: true });
const method = source.method || 'GET';
// contentType 해석 (multipart/form-data 지원)
const resolvedContentType = source.contentType
? (typeof source.contentType === 'string' && source.contentType.includes('{{'))
? resolveExpressionString(source.contentType, expressionContext, { skipCache: true })
: source.contentType
: 'application/json';
const isMultipart = resolvedContentType === 'multipart/form-data';
// IMPORTANT: source.params를 deep clone하여 원본 보존 (레이아웃 캐시 변형 방지)
// multipart/form-data인 경우 File/Blob 객체가 JSON 직렬화로 소실되므로 shallow clone 사용
let params = isMultipart
? { ...(source.params || {}) }
: JSON.parse(JSON.stringify(source.params || {}));
// 파라미터 치환 (resolveParamValue 사용)
// {{route.id}}, {{route?.id}}, {{query.page}}, 복잡한 표현식 모두 지원
// IMPORTANT: 배열 등 원본 타입 유지를 위해 resolveParamValue 사용
// (resolveExpressionString은 항상 문자열 반환하여 배열이 JSON 문자열로 변환됨)
Object.entries(params).forEach(([key, value]) => {
if (typeof value === 'string') {
params[key] = resolveParamValue(value, expressionContext);
}
});
// 빈 문자열인 파라미터 제거 (API로 전송하지 않음)
Object.keys(params).forEach((key) => {
if (params[key] === '') {
delete params[key];
}
});
// DevTools 요청 추적
const devTools = getDevTools();
let requestId: string | null = null;
if (devTools?.isEnabled()) {
// 데이터소스 정의 정보 등록
devTools.trackDataSourceDefinition({
id: source.id,
type: source.type,
endpoint: endpoint,
method: method,
autoFetch: source.auto_fetch,
initLocal: source.initLocal,
initGlobal: source.initGlobal,
});
// DevTools용 전체 URL 생성 (쿼리 파라미터 포함)
let trackingUrl = endpoint;
if (method === 'GET' && Object.keys(params).length > 0) {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach(v => searchParams.append(key, String(v)));
} else if (value != null) {
searchParams.append(key, String(value));
}
});
const queryString = searchParams.toString();
trackingUrl = queryString ? `${endpoint}?${queryString}` : endpoint;
}
requestId = devTools.trackRequest(trackingUrl, method, {
requestBody: method !== 'GET' ? params : undefined,
dataSourceId: source.id,
});
devTools.trackDataSourceLoading(source.id);
}
try {
let result: any;
// auth_mode 결정 (auth_required: true는 'required'로 처리하여 하위 호환성 유지)
const authMode = source.auth_mode ?? (source.auth_required === true ? 'required' : 'none');
// 토큰 유무 확인
const hasToken = AuthManager.getInstance().isAuthenticated();
// auth_mode: 'required'이고 토큰이 없으면 요청 스킵 (fallback 사용)
// 토큰 없이 ApiClient로 요청하면 401 → onUnauthorized → 로그인 리다이렉트 발생
if (authMode === 'required' && !hasToken) {
logger.log(`[DataSourceManager] Skipping ${source.id}: auth_mode is 'required' but no token available`);
if (devTools?.isEnabled()) {
devTools.trackDataSourceError(source.id, 'Skipped: auth_mode required but not authenticated');
if (requestId) {
devTools.failRequest(requestId, 'Skipped: not authenticated');
}
}
const skipError = new Error('Auth required but not authenticated') as any;
skipError.response = { status: 401, statusText: 'Unauthorized', data: null };
skipError.status = 401;
skipError._authSkipped = true;
throw skipError;
}
// ApiClient 사용 여부 결정
// - required: ApiClient 사용 (토큰 존재 확인됨)
// - optional: 토큰이 있을 때만 ApiClient 사용
// - none: 일반 fetch 사용
const useApiClient = authMode === 'required' || (authMode === 'optional' && hasToken);
if (useApiClient) {
const apiClient = getApiClient();
// ApiClient는 baseURL로 '/api'를 사용하므로, endpoint에서 '/api' 제거
const normalizedEndpoint = endpoint.startsWith('/api/')
? endpoint.substring(4) // '/api/' 제거 -> '/'로 시작
: endpoint.startsWith('/api')
? endpoint.substring(4) || '/' // '/api' 제거 -> '/' 또는 원래 경로
: endpoint;
if (this.options.onError) {
logger.log(`[DataSourceManager] Normalized endpoint: ${endpoint} -> ${normalizedEndpoint}`);
}
// 전역 헤더 + 커스텀 헤더 병합 (source.headers가 globalHeaders보다 우선)
// 1. globalHeaders에서 패턴 매칭된 헤더 추출
const globalHeadersResolved = this.getMatchingGlobalHeaders(endpoint, expressionContext);
// 2. source.headers 처리 (개별 데이터소스 헤더)
const sourceHeaders: Record<string, string> = {};
if (source.headers) {
Object.entries(source.headers).forEach(([headerName, headerValue]) => {
// 표현식 평가 ({{...}} 치환)
const resolvedValue = resolveParamValue(headerValue, expressionContext);
// null, undefined, 빈 문자열이 아닌 경우에만 헤더 추가
if (resolvedValue != null && resolvedValue !== '') {
sourceHeaders[headerName] = String(resolvedValue);
}
});
}
// 3. 병합 (source.headers가 globalHeaders를 덮어씀)
const customHeaders: Record<string, string> = {
...globalHeadersResolved,
...sourceHeaders,
};
// 커스텀 헤더가 있는 경우 config에 포함
const hasCustomHeaders = Object.keys(customHeaders).length > 0;
// multipart/form-data인 경우 params를 FormData로 변환
// Axios는 FormData를 받으면 Content-Type을 자동으로 multipart/form-data(+boundary)로 설정
const requestData = isMultipart ? this.toFormData(params) : params;
// HTTP 메서드에 따라 요청
switch (method) {
case 'GET':
result = await apiClient.get(normalizedEndpoint, {
params,
...(hasCustomHeaders && { headers: customHeaders }),
});
break;
case 'POST':
if (hasCustomHeaders) {
result = await apiClient.post(normalizedEndpoint, requestData, { headers: customHeaders });
} else {
result = await apiClient.post(normalizedEndpoint, requestData);
}
break;
case 'PUT':
if (hasCustomHeaders) {
result = await apiClient.put(normalizedEndpoint, requestData, { headers: customHeaders });
} else {
result = await apiClient.put(normalizedEndpoint, requestData);
}
break;
case 'PATCH':
if (hasCustomHeaders) {
result = await apiClient.patch(normalizedEndpoint, requestData, { headers: customHeaders });
} else {
result = await apiClient.patch(normalizedEndpoint, requestData);
}
break;
case 'DELETE':
result = await apiClient.delete(normalizedEndpoint, {
params,
...(hasCustomHeaders && { headers: customHeaders }),
});
break;
default:
throw new Error(`Unsupported HTTP method: ${method}`);
}
} else {
// auth_mode가 'none'이거나 'optional'인데 토큰이 없는 경우 일반 fetch 사용
let url = endpoint;
// GET 요청인 경우 쿼리 스트링 생성
if (method === 'GET') {
const queryString = new URLSearchParams(params).toString();
url = queryString ? `${endpoint}?${queryString}` : endpoint;
}
// 기본 헤더 (multipart/form-data는 Content-Type 생략 - 브라우저 자동 설정)
const requestHeaders: Record<string, string> = {
...(isMultipart ? {} : { 'Content-Type': 'application/json' }),
'Accept': 'application/json',
};
// g7_locale이 설정되어 있으면 Accept-Language 헤더로 전송
if (typeof window !== 'undefined') {
const locale = localStorage.getItem('g7_locale');
if (locale) {
requestHeaders['Accept-Language'] = locale;
}
}
// 전역 헤더 적용 (globalHeaders)
const globalHeadersResolved = this.getMatchingGlobalHeaders(endpoint, expressionContext);
Object.assign(requestHeaders, globalHeadersResolved);
// 커스텀 헤더 처리 (source.headers가 globalHeaders보다 우선)
if (source.headers) {
Object.entries(source.headers).forEach(([headerName, headerValue]) => {
// 표현식 평가 ({{...}} 치환)
const resolvedValue = resolveParamValue(headerValue, expressionContext);
// null, undefined, 빈 문자열이 아닌 경우에만 헤더 추가
if (resolvedValue != null && resolvedValue !== '') {
requestHeaders[headerName] = String(resolvedValue);
}
});
}
// multipart/form-data인 경우 FormData로 변환
let fetchBody: BodyInit | undefined;
if (method !== 'GET') {
if (isMultipart) {
fetchBody = this.toFormData(params);
// multipart 시 Content-Type 헤더 제거 (FormData 사용 시 브라우저가 boundary 포함하여 설정)
delete requestHeaders['Content-Type'];
} else {
fetchBody = JSON.stringify(params);
}
}
const response = await fetch(url, {
method,
headers: requestHeaders,
body: fetchBody,
});
// 응답 본문 파싱 (성공/실패 모두)
let responseBody: any;
try {
responseBody = await response.json();
} catch {
responseBody = null;
}
// DevTools 요청 완료 추적 (성공/실패 모두 상태 코드와 응답 기록)
if (requestId && devTools?.isEnabled()) {
devTools.completeRequest(requestId, response.status, responseBody);
// 이미 기록했으므로 catch에서 중복 기록 방지
requestId = null;
}
if (!response.ok) {
// 에러 응답도 DevTools에 기록 후 throw
if (devTools?.isEnabled()) {
devTools.trackDataSourceError(source.id, `HTTP ${response.status}: ${response.statusText}`);
}
// response 정보를 Error에 포함하여 상위 catch에서 정확한 상태 코드 추출 가능
const httpError = new Error(`HTTP error! status: ${response.status}`) as any;
httpError.response = {
status: response.status,
statusText: response.statusText,
data: responseBody,
};
httpError.status = response.status;
throw httpError;
}
result = responseBody;
// DevTools 데이터소스 로드 완료 추적
if (devTools?.isEnabled()) {
devTools.trackDataSourceLoaded(source.id, result);
}
}
// DevTools 요청 완료 추적 (ApiClient 사용 시)
if (useApiClient && requestId && devTools?.isEnabled()) {
// ApiClient는 성공 응답만 반환하므로 200으로 기록
// (에러 시 catch 블록으로 이동)
devTools.completeRequest(requestId, 200, result);
devTools.trackDataSourceLoaded(source.id, result);
}
return result;
} catch (error) {
// DevTools 요청 실패 추적 (ApiClient 에러 또는 네트워크 에러)
if (requestId && devTools?.isEnabled()) {
// axios 에러인 경우 상태 코드와 응답 데이터 추출
const axiosError = error as any;
if (axiosError?.response) {
// HTTP 에러 응답이 있는 경우 상태 코드와 응답 본문 기록
devTools.completeRequest(
requestId,
axiosError.response.status,
axiosError.response.data
);
} else {
// 네트워크 에러 등 응답이 없는 경우
devTools.failRequest(requestId, error instanceof Error ? error.message : String(error));
}
devTools.trackDataSourceError(source.id, error instanceof Error ? error.message : String(error));
}
throw error;
}
}
/**
* 캐시된 데이터 조회
*
* TTL이 만료된 캐시는 자동으로 삭제됩니다.
*
* @param sourceId 데이터 소스 ID
* @returns 캐시된 데이터 (없거나 만료되면 undefined)
*/
getCachedData(sourceId: string): any | undefined {
const entry = this.dataCache.get(sourceId);
if (!entry) {
return undefined;
}
// TTL 체크
const now = Date.now();
if (now - entry.timestamp > DataSourceManager.DATA_CACHE_TTL) {
this.dataCache.delete(sourceId);
return undefined;
}
return entry.data;
}
/**
* 캐시 초기화
*/
clearCache(): void {
this.dataCache.clear();
}
/**
* WebSocket 데이터 소스를 구독합니다.
*
* type이 'websocket'인 데이터 소스를 필터링하여
* WebSocketManager를 통해 채널을 구독하고 이벤트를 리스닝합니다.
*
* @param sources 데이터 소스 배열
* @param onUpdate 데이터 업데이트 시 호출될 콜백 (sourceId, data)
* @returns 구독 키 배열 (구독 해제 시 사용)
*/
subscribeWebSockets(
sources: DataSource[],
onUpdate: (sourceId: string, data: unknown) => void,
bindingContext: Record<string, any> = {}
): string[] {
const subscriptionKeys: string[] = [];
const webSocketSources = sources.filter((s) => s.type === 'websocket');
if (webSocketSources.length === 0) {
return subscriptionKeys;
}
webSocketSources.forEach((source) => {
if (!source.channel || !source.event) {
logger.warn(`WebSocket source ${source.id} missing channel or event`);
return;
}
// 채널/이벤트명에 포함된 표현식 평가 (예: core.user.notifications.{{current_user.data.uuid}})
// 평가 실패 시 원본 문자열 유지 — 정적 채널은 영향 없음
let resolvedChannel: string;
let resolvedEvent: string;
try {
resolvedChannel = resolveExpressionString(source.channel, bindingContext, { skipCache: true });
resolvedEvent = resolveExpressionString(source.event, bindingContext, { skipCache: true });
} catch (error) {
logger.error(`Failed to resolve WebSocket channel/event for source ${source.id}:`, error);
return;
}
// 평가 결과 검증: 표현식 미평가, 빈 세그먼트(trailing/leading/연속 dot), 빈 문자열은 모두 잘못된 채널
// 표현식이 undefined로 평가되어 빈 문자열로 치환된 경우 trailing dot 등이 발생함
const isInvalidChannel = (channel: string): boolean => {
if (!channel || channel.trim() === '') return true;
if (channel.includes('{{')) return true; // 미평가 표현식
if (channel.endsWith('.') || channel.endsWith(':')) return true; // trailing 빈 세그먼트
if (channel.startsWith('.') || channel.startsWith(':')) return true; // leading 빈 세그먼트
if (channel.includes('..') || channel.includes('::')) return true; // 중간 빈 세그먼트
return false;
};
if (isInvalidChannel(resolvedChannel) || isInvalidChannel(resolvedEvent)) {
logger.warn(
`WebSocket source ${source.id} has invalid channel/event after expression resolution, skipping. ` +
`Original channel="${source.channel}", resolved="${resolvedChannel}". ` +
`Original event="${source.event}", resolved="${resolvedEvent}". ` +
`Available context keys: [${Object.keys(bindingContext).join(', ')}]`
);
return;
}
const key = webSocketManager.subscribe(
resolvedChannel,
resolvedEvent,
(data: unknown) => {
// target_source가 지정된 경우 해당 소스의 캐시 업데이트
const targetId = source.target_source || source.id;
this.dataCache.set(targetId, { data, timestamp: Date.now() });
onUpdate(targetId, data);
},
{ channelType: source.channel_type || 'private' }
);
if (key) {
subscriptionKeys.push(key);
}
});
return subscriptionKeys;
}
/**
* WebSocket 구독을 해제합니다.
*
* @param subscriptionKeys 구독 키 배열
*/
unsubscribeWebSockets(subscriptionKeys: string[]): void {
if (subscriptionKeys.length === 0) {
return;
}
subscriptionKeys.forEach((key) => {
webSocketManager.unsubscribe(key);
});
}
/**
* onSuccess 핸들러를 실행합니다.
*
* 데이터 소스 fetch 성공 후 정의된 onSuccess 핸들러를 실행합니다.
* params에서 {{response.xxx}} 형태로 응답 데이터에 접근할 수 있습니다.
*
* @param onSuccess onSuccess 핸들러 설정
* @param successContext 성공 컨텍스트 (data, sourceId)
*/
private async executeOnSuccessHandler(
onSuccess: OnSuccessHandler,
successContext: SuccessContext
): Promise<void> {
// 옵션 주입된 dispatcher 우선 (편집기 격리 모드), 없으면 전역 호스트 dispatcher
// (편집기 캔버스의 onSuccess setState 가 호스트 globalState
// 를 변경해 호스트 #app 이 unmount 되는 결함 방지)
const actionDispatcher = this.options.actionDispatcher ?? getActionDispatcher();
if (!actionDispatcher) {
logger.warn('ActionDispatcher not available, skipping onSuccess handler');
return;
}
// 핸들러 배열로 정규화
const handlers = Array.isArray(onSuccess) ? onSuccess : [onSuccess];
// 순차 실행
for (const handler of handlers) {
try {
// response 컨텍스트로 핸들러 실행
// {{response.data}}, {{response.sourceId}} 등으로 접근 가능
await actionDispatcher.dispatchAction(
{
type: 'click', // 이벤트 타입은 의미 없음 (dispatchAction에서 사용하지 않음)
...handler,
},
{
data: {
response: successContext,
},
}
);
} catch (error) {
logger.error(`Failed to execute onSuccess handler:`, error);
throw error;
}
}
}
}
/**
* 전역 데이터 소스 매니저 인스턴스
*/
export const dataSourceManager = new DataSourceManager();