/** * ComponentRegistry.ts * * 템플릿 컴포넌트를 동적으로 로드하고 전역 레지스트리에 등록하는 클래스 * * 역할: * - /build/template/dist/components.js 동적 로딩 * - components.json 매니페스트 검증 * - 컴포넌트 타입별 분류 (basic, composite, layout) * - 전역 레지스트리 관리 및 캐싱 */ import React, { type ComponentType } from 'react'; import { createLogger } from '../utils/Logger'; import { suffixed, extStaticUrl } from '../support/assetUrl'; import { fetchStaticFirst } from '../support/fetchStaticFirst'; const logger = createLogger('ComponentRegistry'); /** * 컴포넌트 타입 정의 */ export type ComponentTypeEnum = 'basic' | 'composite' | 'layout'; /** * 컴포넌트 메타데이터 인터페이스 */ export interface ComponentMetadata { /** 컴포넌트 이름 */ name: string; /** 컴포넌트 타입 */ type: ComponentTypeEnum; /** 컴포넌트 설명 */ description?: string; /** 허용된 props 목록 */ props?: string[]; /** children 허용 여부 */ allowsChildren?: boolean; /** * 바인딩 처리를 건너뛸 props 키 목록 * * 컴포넌트가 내부적으로 row/iteration 컨텍스트 등을 사용하여 * 자체적으로 바인딩을 처리해야 하는 props 키를 지정합니다. * * 예시: DataGrid는 ['cellChildren', 'expandChildren', 'expandContext', 'render'] */ skipBindingKeys?: string[]; /** * Form 자동 바인딩 시 boolean 값의 바인딩 타입 * * - 'checked': 항상 checked prop으로 바인딩 (Toggle, Checkbox 등) * - 'checkable': type이 checkbox/radio일 때만 checked, 그 외 value (Input) * - 미지정: 항상 value prop으로 바인딩 (RadioGroup, Select 등 기본값) */ bindingType?: 'checked' | 'checkable'; } /** * components.json 매니페스트 스키마 */ export interface ComponentManifest { /** 매니페스트 버전 */ version: string; /** 템플릿 식별자 */ templateId: string; /** 컴포넌트 목록 */ components: { /** 기본 컴포넌트 목록 */ basic: ComponentMetadata[]; /** 집합 컴포넌트 목록 */ composite: ComponentMetadata[]; /** 레이아웃 컴포넌트 목록 */ layout: ComponentMetadata[]; }; } /** * 레지스트리 맵 인터페이스 */ export interface RegistryMap { [componentName: string]: { component: ComponentType; metadata: ComponentMetadata; }; } /** * 로딩 상태 타입 */ export type LoadingState = 'idle' | 'loading' | 'loaded' | 'error'; /** * ComponentRegistry 에러 클래스 */ export class ComponentRegistryError extends Error { constructor(message: string, public code: string, public details?: any) { super(message); this.name = 'ComponentRegistryError'; } } /** * ComponentRegistry 클래스 * * 싱글톤 패턴으로 전역 컴포넌트 레지스트리 관리 */ export class ComponentRegistry { private static instance: ComponentRegistry | null = null; /** 매니페스트 캐시 (템플릿별) */ private static manifestCache = new Map(); /** 컴포넌트 레지스트리 맵 */ private registry: RegistryMap = {}; /** 컴포넌트 매니페스트 */ private manifest: ComponentManifest | null = null; /** 확장 캐시 버전 (0 = 무버전 URL — 편집기 경로) */ private cacheVersion = 0; /** 로딩 상태 */ private loadingState: LoadingState = 'idle'; /** 에러 정보 */ private error: Error | null = null; /** 템플릿 ID */ private templateId: string | null = null; /** 템플릿 타입 (admin 또는 user) */ private templateType: string | null = null; /** * private 생성자 (싱글톤 패턴) */ private constructor() {} /** * 싱글톤 인스턴스 반환 */ public static getInstance(): ComponentRegistry { if (!ComponentRegistry.instance) { ComponentRegistry.instance = new ComponentRegistry(); } return ComponentRegistry.instance; } /** * 격리 인스턴스 생성 — 싱글톤과 독립된 별도 ComponentRegistry. * * 레이아웃 편집기 캔버스 처럼 한 페이지에서 호스트 템플릿과 * 편집 대상 템플릿이 동시에 살아 있어야 하는 케이스용. 싱글톤을 점유 중인 * 호스트(`getInstance()`)의 등록 상태와 충돌하지 않도록, 격리 인스턴스는 * 정적 manifestCache 만 공유하고 registry/manifest/loadingState 는 자기 * 인스턴스 안에만 존재한다. * * `loadComponents()` 등 인스턴스 메서드는 싱글톤과 동일하게 사용 가능. * * @since engine-v1.50.0 */ public static createIsolatedInstance(): ComponentRegistry { return new ComponentRegistry(); } /** * 레지스트리 초기화 (테스트용) */ public static resetInstance(): void { ComponentRegistry.instance = null; } /** * 템플릿 컴포넌트 로드 및 등록 * * @param templateId 템플릿 식별자 * @param templateType 템플릿 타입 (admin 또는 user) * @param cacheVersion 확장 캐시 버전 — 0(기본)이면 무버전 URL(편집기 경로, * 서버는 `?v` 생략 시 현재 버전 폴백 #588). 매니페스트 캐시 키에도 포함되어 * 버전 간 교차 오염을 막는다 (#122, @since engine-v1.61.0) */ public async loadComponents(templateId: string, templateType: string, cacheVersion: number = 0): Promise { if (this.loadingState === 'loading') { throw new ComponentRegistryError( 'Components are already being loaded', 'LOADING_IN_PROGRESS' ); } if (this.loadingState === 'loaded' && this.templateId === templateId && this.templateType === templateType) { logger.log('Components already loaded for template:', templateId, templateType); return; } this.loadingState = 'loading'; this.templateId = templateId; this.templateType = templateType; this.cacheVersion = cacheVersion; this.error = null; try { // 1. components.json 매니페스트 로드 await this.loadManifest(); // 2. 컴포넌트 번들 동적 import await this.loadComponentBundle(); // 3. 로딩 완료 this.loadingState = 'loaded'; logger.log('Successfully loaded components:', Object.keys(this.registry).length); } catch (error) { this.loadingState = 'error'; this.error = error instanceof Error ? error : new Error(String(error)); throw new ComponentRegistryError( `Failed to load components: ${this.error.message}`, 'LOAD_FAILED', { originalError: this.error } ); } } /** * components.json 매니페스트 로드 및 검증 (캐싱 지원) */ private async loadManifest(): Promise { try { if (!this.templateId) { throw new ComponentRegistryError( 'Template ID not set', 'TEMPLATE_ID_NOT_SET' ); } // 캐시 키 생성 (버전 포함 — 확장 라이프사이클 후 stale 매니페스트 교차 오염 방지) const cacheKey = `${this.templateId}:${this.templateType}:v${this.cacheVersion || 0}`; // 캐시 확인 if (ComponentRegistry.manifestCache.has(cacheKey)) { this.manifest = ComponentRegistry.manifestCache.get(cacheKey)!; logger.log('Manifest loaded from cache:', this.manifest.templateId); return; } // 캐시 미스 - API에서 로드 // 네트워크 일시 실패(응답 없음)에만 재시도. HTTP 에러는 아래 !ok 분기가 종전대로 처리. // @since engine-v1.53.0 const manifestUrl = suffixed( `/api/templates/${this.templateId}/components`, 'json', this.cacheVersion > 0 ? this.cacheVersion : null, ); // 정적 게시본(bake) 우선 (#122) — 편집기 경로(v0)는 legacy 직행 유지. // miss 는 fetchStaticFirst 가 legacy 로 폴백하고, legacy 측이 fetchWithRetry 를 재사용한다. const response = await fetchStaticFirst( this.cacheVersion > 0 ? extStaticUrl(`templates/${this.templateId}/components.json`, this.cacheVersion) : null, manifestUrl, { label: 'components.json' } ); if (!response.ok) { throw new ComponentRegistryError( `Failed to fetch manifest: ${response.status} ${response.statusText}`, 'MANIFEST_FETCH_FAILED', { status: response.status, statusText: response.statusText } ); } const manifest = await response.json(); // 매니페스트 검증 this.validateManifest(manifest); this.manifest = manifest; // 캐시에 저장 ComponentRegistry.manifestCache.set(cacheKey, manifest); logger.log('Manifest loaded and cached:', manifest.templateId); } catch (error) { if (error instanceof ComponentRegistryError) { throw error; } throw new ComponentRegistryError( 'Failed to load component manifest', 'MANIFEST_LOAD_FAILED', { originalError: error } ); } } /** * 매니페스트 검증 */ private validateManifest(manifest: any): void { // 필수 필드 검증 if (!manifest.version) { throw new ComponentRegistryError( 'Manifest missing required field: version', 'MANIFEST_INVALID', { field: 'version' } ); } if (!manifest.templateId) { throw new ComponentRegistryError( 'Manifest missing required field: templateId', 'MANIFEST_INVALID', { field: 'templateId' } ); } // 컴포넌트 객체 검증 if (!manifest.components || typeof manifest.components !== 'object') { throw new ComponentRegistryError( 'Manifest missing required field: components', 'MANIFEST_INVALID', { field: 'components' } ); } // 컴포넌트 배열 검증 const componentTypes: ComponentTypeEnum[] = ['basic', 'composite', 'layout']; for (const type of componentTypes) { if (!Array.isArray(manifest.components[type])) { throw new ComponentRegistryError( `Manifest field 'components.${type}' must be an array`, 'MANIFEST_INVALID', { field: `components.${type}`, value: manifest.components[type] } ); } // 각 컴포넌트 메타데이터 검증 manifest.components[type].forEach((meta: any, index: number) => { if (!meta.name || typeof meta.name !== 'string') { throw new ComponentRegistryError( `Invalid component metadata at ${type}[${index}]: missing or invalid 'name'`, 'MANIFEST_INVALID', { type, index, metadata: meta } ); } if (!meta.type || typeof meta.type !== 'string') { throw new ComponentRegistryError( `Invalid component metadata at ${type}[${index}]: missing or invalid 'type'`, 'MANIFEST_INVALID', { type, index, metadata: meta } ); } }); } logger.log('Manifest validation passed'); } /** * 컴포넌트 번들에서 컴포넌트 가져오기 * * admin.blade.php에서 이미 IIFE 번들을 로드했으므로, * 전역 변수에서 컴포넌트를 가져옵니다 (HTTP 요청 불필요). */ private async loadComponentBundle(): Promise { try { if (!this.templateId) { throw new ComponentRegistryError( 'Template ID not set', 'TEMPLATE_ID_NOT_SET' ); } // 전역 객체에서 컴포넌트 가져오기 // admin.blade.php에서 이미