Files
Gnuboard7/resources/js/core/template-engine/DataSourceManager.ts
T
2026-07-01 10:30:32 +09:00

2027 lines
72 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 { 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
const singleExpressionMatch = value.match(/^\{\{(.+)\}\}$/);
if (singleExpressionMatch) {
// 단일 표현식인 경우 evaluateExpression으로 원본 타입 유지
const expression = singleExpressionMatch[1].trim();
try {
return 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();