perf(core): 코어 번들 3분할로 초기 로딩 최적화
레이아웃 편집기·개발자 진단도구를 코어 번들에서 분리해 각각 편집 화면 진입 시·디버그 모드 시에만 지연 로드하도록 변경. 초기 접속 gzip payload 458KB → 221KB (52% 감소). lazy 번들은 window.G7Core.__runtime 으로 코어 런타임을 공유해 React/컨텍스트/싱글톤 단일 인스턴스를 보장(재번들 0중복). 코어 7.0.1 bump.
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@ APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.0
|
||||
APP_VERSION=7.0.1
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=ko
|
||||
|
||||
@@ -3,7 +3,7 @@ APP_ENV=testing
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.0
|
||||
APP_VERSION=7.0.1
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=ko
|
||||
|
||||
@@ -776,6 +776,18 @@ php artisan module:update sirsoft-ecommerce --force
|
||||
php artisan plugin:update sirsoft-payment --force
|
||||
```
|
||||
|
||||
### 코어 3-번들 구조 + 공유 런타임 (engine-v1.51.0+)
|
||||
|
||||
`core:build` 는 코어 프론트엔드를 3개 IIFE 번들로 빌드한다:
|
||||
|
||||
| 번들 | 로드 시점 | vite config |
|
||||
|------|----------|-------------|
|
||||
| `template-engine.min.js` | 모든 페이지 (동기 `<script>`) | `vite.config.core.js` |
|
||||
| `layout-editor.min.js` | `/admin/layout-editor/*` 진입 시 런타임 주입 | `vite.config.editor.js` |
|
||||
| `devtools.min.js` | 디버그 모드에서만 런타임 주입 | `vite.config.devtools.js` |
|
||||
|
||||
lazy 번들(편집기/devtools)이 코어 런타임(DynamicRenderer·엔진 싱글톤·React Context·DevTools 코어)을 재사용할 때는 재번들하지 않고 `window.G7Core.__runtime` 을 빌려 쓴다 — React/컨텍스트/싱글톤 인스턴스 동일성이 강제되기 때문(사본이 둘이면 "Invalid hook call"·컨텍스트 미매칭). 메인 번들이 `G7CoreGlobals` 에서 공유 대상을 `G7Core.__runtime` 에 노출하고, lazy 번들 vite config 는 React 4종(`react`/`react-dom`/`react-dom/client`/`react/jsx-runtime`)을 external→window 로, 코어 런타임 모듈을 `resolveId` 플러그인으로 `__runtime-shims/` 로 치환한다.
|
||||
|
||||
### 빌드 명령어 (Artisan)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [7.0.1] - 2026-07-01
|
||||
|
||||
### Changed
|
||||
|
||||
- 첫 화면 접속 시 내려받는 코어 스크립트 용량을 절반 이하로 줄여 초기 로딩을 개선했습니다. 레이아웃 편집기와 개발자 진단도구를 코어에서 분리해, 편집기는 편집 화면에 들어갈 때만, 진단도구는 디버그 모드를 켰을 때만 내려받도록 했습니다. 일반 사용자는 평소 쓰지 않는 편집기·진단도구 코드를 더 이상 받지 않습니다.
|
||||
|
||||
## [7.0.0] - 2026-07-01
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -246,7 +246,7 @@ unzip g7-release.zip
|
||||
|
||||
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
|
||||
ls -la
|
||||
# (필요 시) mv g7-7.0.0 g7
|
||||
# (필요 시) mv g7-7.0.1 g7
|
||||
|
||||
# ZIP 파일 정리 (선택)
|
||||
rm g7-release.zip
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.0-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/version-7.0.1-blue" alt="Version"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
|
||||
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
|
||||
|
||||
@@ -85,27 +85,95 @@ class BuildCoreCommand extends Command
|
||||
*/
|
||||
private function buildEngineOnly(string $projectPath, bool $watchMode, bool $productionMode): int
|
||||
{
|
||||
$buildCommand = ['npm', 'run'];
|
||||
|
||||
// 감시 모드: 엔진 번들 + 편집기 번들(layout-editor.min.js)을 각각 vite --watch 로
|
||||
// 병렬 감시한다. (기존 dev 서버는 코어 lib 를 빌드하지 않으므로 사용 불가)
|
||||
if ($watchMode) {
|
||||
// build:core는 watch 모드가 없으므로 dev 사용
|
||||
$buildCommand[] = 'dev';
|
||||
$this->info('👀 파일 감시 모드로 코어 빌드 시작 (템플릿 엔진)');
|
||||
$this->info('👀 파일 감시 모드로 코어 빌드 시작 (템플릿 엔진 + 레이아웃 편집기)');
|
||||
$this->line(' Ctrl+C로 종료할 수 있습니다.');
|
||||
} else {
|
||||
$buildCommand[] = 'build:core';
|
||||
$this->info('🔨 코어 빌드 시작 (템플릿 엔진)'.($productionMode ? ' (프로덕션)' : ''));
|
||||
|
||||
return $this->runWatchBundles($projectPath);
|
||||
}
|
||||
|
||||
$result = $this->runNpmCommand($buildCommand, $projectPath, ! $watchMode);
|
||||
// ── 1) 템플릿 엔진 번들 (편집기 코드 제외) ──────────────────────────────
|
||||
$this->info('🔨 코어 빌드 시작 (템플릿 엔진)'.($productionMode ? ' (프로덕션)' : ''));
|
||||
$engineResult = $this->runNpmCommand(['npm', 'run', 'build:core'], $projectPath, true);
|
||||
|
||||
if ($result === Command::SUCCESS && ! $watchMode) {
|
||||
$this->info('✅ 코어 빌드 완료 (템플릿 엔진)');
|
||||
if ($engineResult !== Command::SUCCESS) {
|
||||
return $engineResult;
|
||||
}
|
||||
|
||||
// ── 2) 레이아웃 편집기 번들 (lazy, /admin/layout-editor/* 진입 시 로드) ──
|
||||
$this->info('🔨 코어 빌드 시작 (레이아웃 편집기)'.($productionMode ? ' (프로덕션)' : ''));
|
||||
$editorResult = $this->runNpmCommand(['npm', 'run', 'build:core-editor'], $projectPath, true);
|
||||
|
||||
if ($editorResult !== Command::SUCCESS) {
|
||||
$this->error('❌ 레이아웃 편집기 번들 빌드 실패');
|
||||
|
||||
return $editorResult;
|
||||
}
|
||||
|
||||
// ── 3) DevTools 번들 (lazy, 디버그 모드에서만 로드) ──
|
||||
$this->info('🔨 코어 빌드 시작 (DevTools)'.($productionMode ? ' (프로덕션)' : ''));
|
||||
$devtoolsResult = $this->runNpmCommand(['npm', 'run', 'build:core-devtools'], $projectPath, true);
|
||||
|
||||
if ($devtoolsResult !== Command::SUCCESS) {
|
||||
$this->error('❌ DevTools 번들 빌드 실패');
|
||||
|
||||
return $devtoolsResult;
|
||||
}
|
||||
|
||||
$this->info('✅ 코어 빌드 완료 (템플릿 엔진 + 레이아웃 편집기 + DevTools)');
|
||||
$this->showEngineBuildResults($projectPath);
|
||||
$this->incrementExtensionCacheVersion();
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
return $result;
|
||||
/**
|
||||
* 감시 모드에서 엔진 번들 + 편집기 번들을 병렬로 vite --watch 실행합니다.
|
||||
*
|
||||
* @param string $projectPath 프로젝트 경로
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
private function runWatchBundles(string $projectPath): int
|
||||
{
|
||||
// 엔진 + 편집기 + DevTools 번들을 각각 vite --watch 로 병렬 감시
|
||||
$bundles = [
|
||||
'engine' => ['npm', 'run', 'build:core-watch'],
|
||||
'editor' => ['npm', 'run', 'build:core-editor-watch'],
|
||||
'devtools' => ['npm', 'run', 'build:core-devtools-watch'],
|
||||
];
|
||||
|
||||
/** @var array<string, Process> $processes */
|
||||
$processes = [];
|
||||
|
||||
foreach ($bundles as $label => $command) {
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
$command = array_merge(['cmd', '/c'], $command);
|
||||
}
|
||||
|
||||
$process = new Process($command);
|
||||
$process->setWorkingDirectory($projectPath);
|
||||
$process->setTimeout(null);
|
||||
$process->start(function ($type, $buffer) use ($label) {
|
||||
$this->output->write("[{$label}] ".$buffer);
|
||||
});
|
||||
|
||||
$processes[$label] = $process;
|
||||
}
|
||||
|
||||
// 하나라도 실행 중이면 계속 대기 (Ctrl+C 로 종료)
|
||||
do {
|
||||
$anyRunning = false;
|
||||
foreach ($processes as $process) {
|
||||
if ($process->isRunning()) {
|
||||
$anyRunning = true;
|
||||
}
|
||||
}
|
||||
usleep(100000); // 100ms
|
||||
} while ($anyRunning);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -155,12 +223,26 @@ class BuildCoreCommand extends Command
|
||||
|
||||
$this->line(' 빌드 결과:');
|
||||
|
||||
// template-engine.min.js 확인
|
||||
// template-engine.min.js 확인 (일반 페이지 초기 로드 = 이 번들만)
|
||||
$engineFile = $corePath.'/template-engine.min.js';
|
||||
if (file_exists($engineFile)) {
|
||||
$fileSize = number_format(filesize($engineFile) / 1024, 2);
|
||||
$this->line(" - template-engine.min.js ({$fileSize} KB)");
|
||||
}
|
||||
|
||||
// layout-editor.min.js 확인 (편집기 lazy 번들, /admin/layout-editor/* 진입 시 로드)
|
||||
$editorFile = $corePath.'/layout-editor.min.js';
|
||||
if (file_exists($editorFile)) {
|
||||
$fileSize = number_format(filesize($editorFile) / 1024, 2);
|
||||
$this->line(" - layout-editor.min.js ({$fileSize} KB, lazy)");
|
||||
}
|
||||
|
||||
// devtools.min.js 확인 (DevTools lazy 번들, 디버그 모드에서만 로드)
|
||||
$devtoolsFile = $corePath.'/devtools.min.js';
|
||||
if (file_exists($devtoolsFile)) {
|
||||
$fileSize = number_format(filesize($devtoolsFile) / 1024, 2);
|
||||
$this->line(" - devtools.min.js ({$fileSize} KB, lazy/debug)");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -231,7 +231,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'version' => env('APP_VERSION', '7.0.0'),
|
||||
'version' => env('APP_VERSION', '7.0.1'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"build:core": "vite build --config vite.config.core.js",
|
||||
"build:core-editor": "vite build --config vite.config.editor.js",
|
||||
"build:core-devtools": "vite build --config vite.config.devtools.js",
|
||||
"build:core-watch": "vite build --config vite.config.core.js --watch",
|
||||
"build:core-editor-watch": "vite build --config vite.config.editor.js --watch",
|
||||
"build:core-devtools-watch": "vite build --config vite.config.devtools.js --watch",
|
||||
"dev": "vite",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
|
||||
Vendored
+1561
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+96
-1620
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -28,6 +28,23 @@ vi.mock('../template-engine/ActionDispatcher', () => ({
|
||||
|
||||
vi.mock('../template-engine/DataBindingEngine', () => ({
|
||||
DataBindingEngine: vi.fn(),
|
||||
// @since engine-v1.51.0 __runtime 노출용 싱글톤 인스턴스
|
||||
dataBindingEngine: {},
|
||||
}));
|
||||
|
||||
// @since engine-v1.51.0 __runtime(편집기 lazy 번들 공유) 노출용 모듈 mock
|
||||
vi.mock('../template-engine/DataSourceManager', () => ({
|
||||
DataSourceManager: vi.fn(),
|
||||
dataSourceManager: {},
|
||||
}));
|
||||
|
||||
vi.mock('../template-engine/DynamicRenderer', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../template-engine/ResponsiveManager', () => ({
|
||||
responsiveManager: {},
|
||||
BREAKPOINT_PRESETS: {},
|
||||
}));
|
||||
|
||||
vi.mock('../template-engine/TransitionContext', () => ({
|
||||
@@ -36,10 +53,16 @@ vi.mock('../template-engine/TransitionContext', () => ({
|
||||
|
||||
vi.mock('../template-engine/TranslationContext', () => ({
|
||||
useTranslation: vi.fn(() => ({ t: (key: string) => key })),
|
||||
// @since engine-v1.51.0 __runtime 노출용
|
||||
TranslationProvider: () => null,
|
||||
TranslationReactContext: {},
|
||||
}));
|
||||
|
||||
vi.mock('../template-engine/ResponsiveContext', () => ({
|
||||
useResponsive: vi.fn(() => ({ width: 1024, isMobile: false })),
|
||||
// @since engine-v1.51.0 __runtime 노출용
|
||||
ResponsiveProvider: () => null,
|
||||
ResponsiveContext: {},
|
||||
}));
|
||||
|
||||
vi.mock('../auth/AuthManager', () => ({
|
||||
@@ -148,6 +171,52 @@ describe('G7CoreGlobals - initializeG7CoreGlobals()', () => {
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// @since engine-v1.51.0 — 편집기 lazy 번들 공유용 코어 런타임 표면
|
||||
it('window.G7Core.__runtime 에 코어 런타임 표면을 노출해야 함', () => {
|
||||
const deps = createMockDependencies();
|
||||
initializeG7CoreGlobals(deps);
|
||||
|
||||
const runtime = (window as any).G7Core.__runtime;
|
||||
expect(runtime).toBeDefined();
|
||||
|
||||
// 편집기 lazy 번들이 shim 으로 빌려 쓰는 동일성 민감 멤버 전수 노출 확인
|
||||
const requiredKeys = [
|
||||
'DynamicRenderer',
|
||||
'ComponentRegistry',
|
||||
'TranslationEngine',
|
||||
'DataSourceManager',
|
||||
'dataSourceManager',
|
||||
'DataBindingEngine',
|
||||
'dataBindingEngine',
|
||||
'ActionDispatcher',
|
||||
'TranslationReactContext',
|
||||
'TranslationProvider',
|
||||
'useTranslation',
|
||||
'ResponsiveContext',
|
||||
'ResponsiveProvider',
|
||||
'useResponsive',
|
||||
'responsiveManager',
|
||||
'BREAKPOINT_PRESETS',
|
||||
'AuthManager',
|
||||
'createLogger',
|
||||
];
|
||||
for (const key of requiredKeys) {
|
||||
expect(runtime[key], `__runtime.${key} 미노출`).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('__runtime 을 중복 초기화해도 멱등해야 함(동일 참조 유지)', () => {
|
||||
const deps = createMockDependencies();
|
||||
initializeG7CoreGlobals(deps);
|
||||
const first = (window as any).G7Core.__runtime;
|
||||
|
||||
// 재초기화 시 기존 __runtime 참조 보존 (편집기 먼저 로드 시나리오 대비)
|
||||
initializeG7CoreGlobals(deps);
|
||||
const second = (window as any).G7Core.__runtime;
|
||||
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
describe('G7CoreGlobals - componentEvent API', () => {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* loadDevToolsBundle 테스트 — DevTools lazy 번들 로더 (engine-v1.52.0)
|
||||
*
|
||||
* DevTools 디버그 전용 모듈(패널/진단엔진/서버커넥터/스타일추적기)은 별도 번들
|
||||
* (devtools.min.js)로 분리되어 디버그 모드에서만 <script> 주입으로 로드된다. 본 테스트는
|
||||
* 로더 계약을 검증한다:
|
||||
* - 이미 로드됨(__devtools 존재) → 즉시 반환, <script> 미주입 (멱등)
|
||||
* - 미로드 → <script> 1회 주입, load 이벤트 후 모듈 묶음 반환
|
||||
* - 동시 다중 호출 → in-flight promise 병합
|
||||
* - 로드 실패(error) → reject
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
describe('loadDevToolsBundle — DevTools lazy 번들 로더', () => {
|
||||
let appendedScripts: HTMLScriptElement[];
|
||||
let originalAppendChild: typeof document.head.appendChild;
|
||||
let loadDevToolsBundle: () => Promise<any>;
|
||||
|
||||
const fakeBundle = {
|
||||
DiagnosticEngine: class {},
|
||||
getServerConnector: () => ({}),
|
||||
getStyleTracker: () => ({}),
|
||||
DevToolsPanel: () => null,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
appendedScripts = [];
|
||||
(window as any).G7Core = {};
|
||||
(window as any).G7Config = { coreDevToolsAsset: '/build/core/devtools.min.js?v=1' };
|
||||
|
||||
document.getElementById('g7-devtools-bundle')?.remove();
|
||||
|
||||
originalAppendChild = document.head.appendChild.bind(document.head);
|
||||
vi.spyOn(document.head, 'appendChild').mockImplementation((node: any) => {
|
||||
if (node.tagName === 'SCRIPT') {
|
||||
appendedScripts.push(node);
|
||||
return node;
|
||||
}
|
||||
return originalAppendChild(node);
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
({ loadDevToolsBundle } = await import('../template-engine/G7CoreGlobals'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.getElementById('g7-devtools-bundle')?.remove();
|
||||
delete (window as any).G7Core;
|
||||
delete (window as any).G7Config;
|
||||
});
|
||||
|
||||
it('이미 로드됨 → 즉시 반환하고 <script> 를 주입하지 않는다', async () => {
|
||||
(window as any).G7Core.__devtools = fakeBundle;
|
||||
|
||||
const result = await loadDevToolsBundle();
|
||||
|
||||
expect(result).toBe(fakeBundle);
|
||||
expect(appendedScripts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('미로드 → <script> 1회 주입 후 load 시 모듈 묶음 반환', async () => {
|
||||
const promise = loadDevToolsBundle();
|
||||
|
||||
expect(appendedScripts).toHaveLength(1);
|
||||
const script = appendedScripts[0];
|
||||
expect(script.id).toBe('g7-devtools-bundle');
|
||||
expect(script.src).toContain('devtools.min.js');
|
||||
expect(script.async).toBe(false);
|
||||
|
||||
(window as any).G7Core.__devtools = fakeBundle;
|
||||
script.dispatchEvent(new Event('load'));
|
||||
|
||||
await expect(promise).resolves.toBe(fakeBundle);
|
||||
});
|
||||
|
||||
it('동시 다중 호출 → <script> 1회만 주입', async () => {
|
||||
const p1 = loadDevToolsBundle();
|
||||
const p2 = loadDevToolsBundle();
|
||||
|
||||
expect(appendedScripts).toHaveLength(1);
|
||||
|
||||
(window as any).G7Core.__devtools = fakeBundle;
|
||||
appendedScripts[0].dispatchEvent(new Event('load'));
|
||||
|
||||
const [r1, r2] = await Promise.all([p1, p2]);
|
||||
expect(r1).toBe(fakeBundle);
|
||||
expect(r2).toBe(fakeBundle);
|
||||
});
|
||||
|
||||
it('로드 실패(error) → reject 한다', async () => {
|
||||
const promise = loadDevToolsBundle();
|
||||
expect(appendedScripts).toHaveLength(1);
|
||||
|
||||
appendedScripts[0].dispatchEvent(new Event('error'));
|
||||
|
||||
await expect(promise).rejects.toThrow(/DevTools 번들 로드 실패/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* loadLayoutEditorBundle 테스트 — 편집기 lazy 번들 로더 (engine-v1.51.0)
|
||||
*
|
||||
* 편집기는 별도 번들(layout-editor.min.js)로 분리되어 `/admin/layout-editor/*` 진입 시에만
|
||||
* <script> 주입으로 로드된다. 본 테스트는 로더의 계약을 검증한다:
|
||||
* - 이미 로드됨(__LayoutEditorChrome 존재) → 즉시 반환, <script> 미주입 (멱등)
|
||||
* - 미로드 → <script> 1회 주입, load 이벤트 후 컴포넌트 반환
|
||||
* - 동시 다중 호출 → in-flight promise 병합 (중복 주입 없음)
|
||||
* - 로드 실패(error) → reject + 재시도 가능
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
describe('loadLayoutEditorBundle — 편집기 lazy 번들 로더', () => {
|
||||
let appendedScripts: HTMLScriptElement[];
|
||||
let originalAppendChild: typeof document.head.appendChild;
|
||||
// 모듈 레벨 in-flight promise 가 테스트 간 잔존하지 않도록 매 테스트 모듈 재로드
|
||||
let loadLayoutEditorBundle: () => Promise<any>;
|
||||
|
||||
beforeEach(async () => {
|
||||
appendedScripts = [];
|
||||
(window as any).G7Core = {};
|
||||
(window as any).G7Config = { coreEditorAsset: '/build/core/layout-editor.min.js?v=123' };
|
||||
|
||||
// 기존 주입 스크립트 제거
|
||||
document.getElementById('g7-layout-editor-bundle')?.remove();
|
||||
|
||||
// head.appendChild 를 가로채 주입된 <script> 를 기록 (실제 네트워크 로드 없음)
|
||||
originalAppendChild = document.head.appendChild.bind(document.head);
|
||||
vi.spyOn(document.head, 'appendChild').mockImplementation((node: any) => {
|
||||
if (node.tagName === 'SCRIPT') {
|
||||
appendedScripts.push(node);
|
||||
return node;
|
||||
}
|
||||
return originalAppendChild(node);
|
||||
});
|
||||
|
||||
// 모듈 재로드로 layoutEditorLoadPromise 초기화 (테스트 격리)
|
||||
vi.resetModules();
|
||||
({ loadLayoutEditorBundle } = await import('../template-engine'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.getElementById('g7-layout-editor-bundle')?.remove();
|
||||
delete (window as any).G7Core;
|
||||
delete (window as any).G7Config;
|
||||
});
|
||||
|
||||
it('이미 로드됨 → 즉시 반환하고 <script> 를 주입하지 않는다', async () => {
|
||||
const fakeChrome = () => null;
|
||||
(window as any).G7Core.__LayoutEditorChrome = fakeChrome;
|
||||
|
||||
const result = await loadLayoutEditorBundle();
|
||||
|
||||
expect(result).toBe(fakeChrome);
|
||||
expect(appendedScripts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('미로드 → <script> 를 1회 주입하고 load 후 컴포넌트를 반환한다', async () => {
|
||||
const fakeChrome = () => null;
|
||||
const promise = loadLayoutEditorBundle();
|
||||
|
||||
// 정확히 1개 주입
|
||||
expect(appendedScripts).toHaveLength(1);
|
||||
const script = appendedScripts[0];
|
||||
expect(script.id).toBe('g7-layout-editor-bundle');
|
||||
expect(script.src).toContain('layout-editor.min.js');
|
||||
expect(script.async).toBe(false);
|
||||
|
||||
// 편집기 번들 로드 시뮬레이션: 컴포넌트 노출 + load 이벤트
|
||||
(window as any).G7Core.__LayoutEditorChrome = fakeChrome;
|
||||
script.dispatchEvent(new Event('load'));
|
||||
|
||||
await expect(promise).resolves.toBe(fakeChrome);
|
||||
});
|
||||
|
||||
it('동시 다중 호출 → in-flight promise 로 병합되어 <script> 는 1회만 주입된다', async () => {
|
||||
const fakeChrome = () => null;
|
||||
|
||||
const p1 = loadLayoutEditorBundle();
|
||||
const p2 = loadLayoutEditorBundle();
|
||||
const p3 = loadLayoutEditorBundle();
|
||||
|
||||
// 세 호출이 하나의 주입으로 병합
|
||||
expect(appendedScripts).toHaveLength(1);
|
||||
|
||||
(window as any).G7Core.__LayoutEditorChrome = fakeChrome;
|
||||
appendedScripts[0].dispatchEvent(new Event('load'));
|
||||
|
||||
const [r1, r2, r3] = await Promise.all([p1, p2, p3]);
|
||||
expect(r1).toBe(fakeChrome);
|
||||
expect(r2).toBe(fakeChrome);
|
||||
expect(r3).toBe(fakeChrome);
|
||||
});
|
||||
|
||||
it('로드 실패(error) → reject 한다', async () => {
|
||||
const promise = loadLayoutEditorBundle();
|
||||
expect(appendedScripts).toHaveLength(1);
|
||||
|
||||
// 네트워크 실패 시뮬레이션
|
||||
appendedScripts[0].dispatchEvent(new Event('error'));
|
||||
|
||||
await expect(promise).rejects.toThrow(/편집기 번들 로드 실패/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* devtools-entry.ts — DevTools lazy 번들 진입점
|
||||
*
|
||||
* @since engine-v1.52.0
|
||||
*
|
||||
* 개발자 진단도구(DevTools)는 디버그 모드(`G7Config.debug` / 환경설정)를 켰을 때만 쓰인다.
|
||||
* 그럼에도 메인 코어 번들(`template-engine.min.js`)에 정적 포함되면 모든 사용자가 받고도
|
||||
* 실행하지 않는 죽은 코드가 된다(패널 UI 만 311KB). 이를 별도 `devtools.min.js` 로 분리해
|
||||
* `initDevToolsAPI()` 가 `isEnabled()` 참일 때만 런타임 `<script>` 주입으로 로드한다.
|
||||
*
|
||||
* 노출: 디버그 전용 무거운 모듈 4종을 `window.G7Core.__devtools` 로 노출.
|
||||
* - DiagnosticEngine (진단 엔진 클래스)
|
||||
* - getServerConnector (서버 커넥터 팩토리)
|
||||
* - getStyleTracker (스타일 추적기 팩토리)
|
||||
* - DevToolsPanel (패널 UI React 컴포넌트)
|
||||
*
|
||||
* `G7DevToolsCore`(추적 코어, 디버그 무관 항상 필요)는 메인 번들에 남아 `window.G7Core.__runtime`
|
||||
* 으로 공유되며, DevToolsPanel 등은 vite.config.devtools.js alias 로 그것을 빌려 쓴다.
|
||||
* React 는 external 로 window.React 재사용.
|
||||
*/
|
||||
|
||||
import { DiagnosticEngine } from './devtools/DiagnosticEngine';
|
||||
import { getServerConnector } from './devtools/ServerConnector';
|
||||
import { getStyleTracker } from './devtools/StyleTracker';
|
||||
import { DevToolsPanel } from './devtools/ui/DevToolsPanel';
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const G7Core = (window as any).G7Core || ((window as any).G7Core = {});
|
||||
|
||||
G7Core.__devtools = {
|
||||
DiagnosticEngine,
|
||||
getServerConnector,
|
||||
getStyleTracker,
|
||||
DevToolsPanel,
|
||||
};
|
||||
|
||||
// 준비 완료 통지 — loadDevToolsBundle() 이 주입 전에 등록한 resolve 호출
|
||||
if (typeof G7Core.__onDevToolsReady === 'function') {
|
||||
try {
|
||||
G7Core.__onDevToolsReady();
|
||||
} catch {
|
||||
/* 콜백 실패 격리 — onload fallback 이 별도 resolve */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { DiagnosticEngine, getServerConnector, getStyleTracker, DevToolsPanel };
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* G7DevToolsCore 런타임 shim — DevTools lazy 번들 전용 (vite.config.devtools.js alias 대상)
|
||||
* @since engine-v1.52.0
|
||||
*
|
||||
* DevTools 추적 코어는 디버그 무관 항상 필요해 메인 번들에 남는다. 디버그 lazy 번들
|
||||
* (DevToolsPanel 등)은 이 shim 으로 메인 번들의 단일 인스턴스를 빌려 쓴다(싱글톤 동일성).
|
||||
* 값은 window.G7Core.__runtime, 타입은 원본에서 재export.
|
||||
*/
|
||||
|
||||
const runtime = (typeof window !== 'undefined'
|
||||
? (window as any).G7Core?.__runtime
|
||||
: undefined) as { G7DevToolsCore?: unknown } | undefined;
|
||||
|
||||
if (!runtime?.G7DevToolsCore) {
|
||||
throw new Error(
|
||||
'[devtools] window.G7Core.__runtime.G7DevToolsCore 이 없습니다. ' +
|
||||
'코어 번들(template-engine.min.js)이 devtools 번들보다 먼저 로드되어야 합니다.'
|
||||
);
|
||||
}
|
||||
|
||||
export const G7DevToolsCore = runtime.G7DevToolsCore as typeof import('../G7DevToolsCore').G7DevToolsCore;
|
||||
export default G7DevToolsCore;
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* layout-editor-entry.ts — 레이아웃 편집기 lazy 번들 진입점
|
||||
*
|
||||
* @since engine-v1.51.0
|
||||
*
|
||||
* 이 파일은 별도 번들(`layout-editor.min.js`)의 엔트리다. 메인 코어 번들
|
||||
* (`template-engine.min.js`)에서 편집기 코드를 제거해 초기 접속 payload 를 줄이고,
|
||||
* `/admin/layout-editor/*` 진입 시에만 이 번들을 런타임 `<script>` 주입으로 로드한다.
|
||||
*
|
||||
* 역할:
|
||||
* 1. `LayoutEditorChrome` **컴포넌트**를 `window.G7Core.__LayoutEditorChrome` 로 노출.
|
||||
* - mount 함수가 아닌 컴포넌트 자체를 노출한다. provider wrapping(Translation/Transition/
|
||||
* Responsive/Slot)은 메인 번들 `template-engine.ts` `renderTemplate()` 이 그대로 유지하고,
|
||||
* 편집기 번들은 `ReactDOM.createRoot` 를 절대 호출하지 않는다(LayoutEditorChrome 규약).
|
||||
* 2. `__onChromeReady` 콜백 호출 — 메인 번들의 `loadLayoutEditorBundle()` 이 주입 전에
|
||||
* 등록해 둔 resolve. `onload` 이벤트를 fallback 경로로 병행한다.
|
||||
*
|
||||
* `LayoutEditorChrome` 를 import 하는 것만으로 편집기 트리 전체가 이 번들에 포함되고,
|
||||
* 모듈 로드 side-effect(`registerCoreWidgets`/`registerCoreEditors`/`exposeLayoutEditorGlobals`
|
||||
* — 메인 번들 stub 큐 flush)가 자동 실행된다.
|
||||
*
|
||||
* 코어 런타임(DynamicRenderer/엔진/컨텍스트)은 `vite.config.editor.js` 의 alias 로
|
||||
* `window.G7Core.__runtime` 에서 빌려 쓴다(0바이트 중복, 싱글톤/컨텍스트 동일성 보장).
|
||||
* React/ReactDOM/jsx-runtime 은 external 로 `window.React` 등을 참조한다.
|
||||
*/
|
||||
|
||||
import { LayoutEditorChrome } from './template-engine/layout-editor/LayoutEditorChrome';
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const G7Core = (window as any).G7Core || ((window as any).G7Core = {});
|
||||
|
||||
// 편집기 셸 컴포넌트 노출 (메인 번들이 provider 트리 안에서 렌더)
|
||||
G7Core.__LayoutEditorChrome = LayoutEditorChrome;
|
||||
|
||||
// 준비 완료 통지 — loadLayoutEditorBundle() 이 주입 전에 등록한 resolve 호출
|
||||
if (typeof G7Core.__onChromeReady === 'function') {
|
||||
try {
|
||||
G7Core.__onChromeReady();
|
||||
} catch {
|
||||
/* 콜백 실패 격리 — onload fallback 이 별도로 resolve */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { LayoutEditorChrome };
|
||||
@@ -24,12 +24,15 @@ import { DataSourceManager } from './template-engine/DataSourceManager';
|
||||
import { ModalDataSourceWrapper } from './template-engine/ModalDataSourceWrapper';
|
||||
import { ParentContextProvider } from './template-engine/ParentContextProvider';
|
||||
import { TemplateNotFoundError } from './template-engine/TemplateEngineError';
|
||||
import { ErrorDisplay } from './template-engine/ErrorDisplay';
|
||||
import { TemplateApp, initTemplateApp } from './TemplateApp';
|
||||
import { createLogger } from './utils/Logger';
|
||||
import { webSocketManager } from './websocket/WebSocketManager';
|
||||
import { initializeG7CoreGlobals, initDevToolsAPI } from './template-engine/G7CoreGlobals';
|
||||
import { checkLayoutEditorMode } from './template-engine/layout-editor/hooks/useEditorMode';
|
||||
import { LayoutEditorChrome } from './template-engine/layout-editor/LayoutEditorChrome';
|
||||
// LayoutEditorChrome 은 정적 import 하지 않는다 — 편집기는 별도 lazy 번들
|
||||
// (layout-editor.min.js)로 분리되어 `/admin/layout-editor/*` 진입 시에만 로드된다.
|
||||
// @since engine-v1.51.0
|
||||
|
||||
const logger = createLogger('TemplateEngine');
|
||||
|
||||
@@ -368,6 +371,85 @@ async function initTemplateEngine(options: InitOptions): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집기 lazy 번들(layout-editor.min.js) 로드 후 LayoutEditorChrome 컴포넌트 반환.
|
||||
*
|
||||
* @since engine-v1.51.0
|
||||
*
|
||||
* 편집기는 메인 코어 번들에서 분리되어 `/admin/layout-editor/*` 진입 시에만 로드된다.
|
||||
* 이미 로드돼 있으면 즉시 반환(멱등), 아니면 `<script>` 를 1회 주입하고 로드 완료를 대기한다.
|
||||
* 동시 다중 호출은 in-flight promise 로 병합해 중복 주입을 막는다.
|
||||
*
|
||||
* @returns LayoutEditorChrome React 컴포넌트
|
||||
* @throws {Error} 스크립트 로드 실패 또는 컴포넌트 미노출 시
|
||||
*/
|
||||
let layoutEditorLoadPromise: Promise<any> | null = null;
|
||||
|
||||
function loadLayoutEditorBundle(): Promise<any> {
|
||||
const g7 = (window as any).G7Core;
|
||||
|
||||
// 이미 로드됨 — 즉시 반환 (멱등)
|
||||
if (g7?.__LayoutEditorChrome) {
|
||||
return Promise.resolve(g7.__LayoutEditorChrome);
|
||||
}
|
||||
|
||||
// 진행 중인 로드가 있으면 재사용 (중복 주입 방지)
|
||||
if (layoutEditorLoadPromise) {
|
||||
return layoutEditorLoadPromise;
|
||||
}
|
||||
|
||||
layoutEditorLoadPromise = new Promise((resolve, reject) => {
|
||||
// 번들 URL — blade 가 주입한 버전 포함 URL 우선, 폴백은 표준 경로
|
||||
const src =
|
||||
(window as any).G7Config?.coreEditorAsset || '/build/core/layout-editor.min.js';
|
||||
const scriptId = 'g7-layout-editor-bundle';
|
||||
|
||||
// 이미 주입된 <script> 재사용
|
||||
const existing = document.getElementById(scriptId) as HTMLScriptElement | null;
|
||||
|
||||
const onReady = () => {
|
||||
const chrome = (window as any).G7Core?.__LayoutEditorChrome;
|
||||
if (chrome) {
|
||||
resolve(chrome);
|
||||
} else {
|
||||
reject(new Error('편집기 번들 로드됨 — 그러나 __LayoutEditorChrome 미노출'));
|
||||
}
|
||||
};
|
||||
|
||||
// 편집기 엔트리가 컴포넌트 노출 직후 호출할 준비 완료 콜백 (onload fallback 병행)
|
||||
(window as any).G7Core = (window as any).G7Core || {};
|
||||
(window as any).G7Core.__onChromeReady = onReady;
|
||||
|
||||
if (existing) {
|
||||
// 주입돼 있으나 아직 실행 전 — load 이벤트 대기
|
||||
existing.addEventListener('load', onReady, { once: true });
|
||||
existing.addEventListener(
|
||||
'error',
|
||||
() => reject(new Error(`편집기 번들 로드 실패: ${src}`)),
|
||||
{ once: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.id = scriptId;
|
||||
script.src = src;
|
||||
script.async = false; // 로드 순서 보존 (메인 번들 이후)
|
||||
script.addEventListener('load', onReady, { once: true });
|
||||
script.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
layoutEditorLoadPromise = null; // 재시도 가능하도록 초기화
|
||||
reject(new Error(`편집기 번들 로드 실패: ${src}`));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
return layoutEditorLoadPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 렌더링
|
||||
*
|
||||
@@ -429,6 +511,28 @@ async function renderTemplate(options: RenderOptions): Promise<void> {
|
||||
const editorMode = checkLayoutEditorMode(window.location.pathname);
|
||||
if (editorMode) {
|
||||
logger.log('레이아웃 편집기 모드 진입', editorMode);
|
||||
|
||||
// 편집기 lazy 번들 로드 (layout-editor.min.js) — 진입 시에만 로드
|
||||
let LayoutEditorChrome: any;
|
||||
try {
|
||||
LayoutEditorChrome = await loadLayoutEditorBundle();
|
||||
} catch (loadError) {
|
||||
logger.error('레이아웃 편집기 번들 로드 실패', loadError);
|
||||
// 컨테이너에 직접 에러 화면 렌더 (CSS 의존성 없는 인라인 스타일)
|
||||
ErrorDisplay.render(options.containerId, {
|
||||
title: '레이아웃 편집기 로드 실패',
|
||||
message:
|
||||
loadError instanceof Error
|
||||
? loadError.message
|
||||
: '편집기 번들을 불러오지 못했습니다.',
|
||||
icon: 'fas fa-triangle-exclamation',
|
||||
showStack: false,
|
||||
showReloadButton: true,
|
||||
debug: debugMode,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const chrome = React.createElement(LayoutEditorChrome, {
|
||||
templateIdentifier: editorMode.templateIdentifier,
|
||||
initialLocale: state.locale,
|
||||
@@ -923,6 +1027,8 @@ export {
|
||||
getActionDispatcher,
|
||||
LayoutLoader,
|
||||
DataSourceManager,
|
||||
// @since engine-v1.51.0 편집기 lazy 번들 로더 (테스트/진단용 노출)
|
||||
loadLayoutEditorBundle,
|
||||
};
|
||||
|
||||
export type {
|
||||
|
||||
@@ -5,6 +5,30 @@
|
||||
>
|
||||
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
|
||||
|
||||
## [engine-v1.52.0] - 2026-07-01
|
||||
|
||||
### Changed
|
||||
|
||||
#### DevTools 번들 분리 (디버그 전용 코드 초기 로드 제거)
|
||||
|
||||
- 개발자 진단도구(DevTools)의 디버그 전용 무거운 모듈(패널 UI/진단엔진/서버커넥터/스타일추적기)을 메인 코어 번들에서 분리해 별도 `devtools.min.js` 로 빌드한다. `initDevToolsAPI()` 가 디버그 모드(`isEnabled()`)일 때만 런타임 `<script>` 주입으로 로드한다. 디버그 꺼진 일반 사용자는 이 코드를 받지 않는다. 편집기 분리(engine-v1.51.0)와 합쳐 초기 접속 gzip payload 458KB → 221KB (약 52% 감소).
|
||||
- `G7CoreGlobals.ts` — `DiagnosticEngine`/`ServerConnector`/`StyleTracker`/`DevToolsPanel` 정적 import 제거. `initDevToolsAPI()` 는 디버그 OFF 시 최소 API 만 노출(번들 미로드), ON 시 `loadDevToolsBundle()`(신규)로 `window.G7Core.__devtools` 를 지연 로드한 뒤 진단/서버덤프/패널을 활성화. `G7DevToolsCore`(추적 코어, 디버그 무관 항상 필요)는 메인 번들에 상주하며 `G7Core.__runtime` 으로 공유.
|
||||
- `devtools-entry.ts` + `vite.config.devtools.js`(신규) — DevTools 번들 엔트리/빌드. React external → `window.React` 단일 인스턴스, `G7DevToolsCore` 는 shim(`devtools/__runtime-shims/`)으로 메인 번들과 공유(재번들 0바이트, 싱글톤 동일성).
|
||||
- `core:build` — 엔진 + 편집기 + DevTools 3개 번들 순차 빌드. `--watch` 는 3개 `vite build --watch` 병렬.
|
||||
|
||||
## [engine-v1.51.0] - 2026-07-01
|
||||
|
||||
### Changed
|
||||
|
||||
#### 레이아웃 편집기 번들 분리 (초기 접속 payload 축소)
|
||||
|
||||
- 코어 번들(`template-engine.min.js`)에서 레이아웃 편집기 코드를 분리해 별도 `layout-editor.min.js` 로 빌드한다. 편집기는 `/admin/layout-editor/*` 진입 시에만 런타임 `<script>` 주입으로 지연 로드된다. 일반 페이지 초기 접속 gzip payload 458KB → 261KB (약 43% 감소).
|
||||
- `template-engine.ts` — `LayoutEditorChrome` 정적 import 제거. `renderTemplate()` 편집기 분기가 `loadLayoutEditorBundle()`(신규)로 번들을 지연 주입한 뒤 `window.G7Core.__LayoutEditorChrome` 를 기존과 동일한 provider 트리(Translation/Transition/Responsive/Slot) 안에서 렌더한다. 로드 실패 시 인라인 에러 화면 폴백. `checkLayoutEditorMode`(dep-free URL 파서)는 코어에 유지.
|
||||
- `G7CoreGlobals.ts` — `initCoreRuntimeExports()`(신규)가 코어 런타임 표면(DynamicRenderer/엔진 싱글톤/컨텍스트/Logger/AuthManager)을 `window.G7Core.__runtime` 으로 노출. 편집기 번들은 빌드 시 alias-shim(`layout-editor/__runtime-shims/`)으로 이 전역을 빌려 써 코어 런타임을 0바이트 중복으로 재사용하고 싱글톤/React Context 동일성을 보장한다.
|
||||
- `layout-editor-entry.ts` + `vite.config.editor.js`(신규) — 편집기 번들 엔트리/빌드 설정. React/ReactDOM/`react/jsx-runtime` 은 external → `window.React` 등 단일 인스턴스 재사용(자체 React 사본 미포함). 코어 런타임은 커스텀 `resolveId` 플러그인으로 shim 치환.
|
||||
- `core:build`(`BuildCoreCommand.php`) — 엔진 번들 + 편집기 번들을 순차 빌드. `--watch` 는 양 번들을 `vite build --watch` 로 병렬 감시.
|
||||
- 기존 편집기 확장점 stub/큐 핸드셰이크(`initLayoutEditorStub` → `exposeLayoutEditorGlobals`, engine-v1.50.0)가 번들 경계를 넘어 그대로 동작(편집기 로드 시 큐 flush).
|
||||
|
||||
## [engine-v1.50.0] - 2026-06-29
|
||||
|
||||
### Added
|
||||
|
||||
@@ -18,19 +18,21 @@ import * as ReactJSXRuntime from 'react/jsx-runtime';
|
||||
import { ComponentRegistry } from './ComponentRegistry';
|
||||
import { TranslationEngine, TranslationContext } from './TranslationEngine';
|
||||
import { ActionDispatcher } from './ActionDispatcher';
|
||||
import { DataBindingEngine } from './DataBindingEngine';
|
||||
import { DataBindingEngine, dataBindingEngine } from './DataBindingEngine';
|
||||
import { DataSourceManager, dataSourceManager } from './DataSourceManager';
|
||||
import DynamicRenderer from './DynamicRenderer';
|
||||
import { useTransitionState } from './TransitionContext';
|
||||
import { useTranslation } from './TranslationContext';
|
||||
import { useResponsive } from './ResponsiveContext';
|
||||
import { useTranslation, TranslationProvider, TranslationReactContext } from './TranslationContext';
|
||||
import { useResponsive, ResponsiveProvider, ResponsiveContext } from './ResponsiveContext';
|
||||
import { responsiveManager, BREAKPOINT_PRESETS } from './ResponsiveManager';
|
||||
import { AuthManager } from '../auth/AuthManager';
|
||||
import { getApiClient } from '../api/ApiClient';
|
||||
import { createLogger, flushEarlyLogs } from '../utils/Logger';
|
||||
import { WebSocketManager } from '../websocket/WebSocketManager';
|
||||
import { G7DevToolsCore } from '../devtools/G7DevToolsCore';
|
||||
import { DiagnosticEngine } from '../devtools/DiagnosticEngine';
|
||||
import { getServerConnector } from '../devtools/ServerConnector';
|
||||
import { DevToolsPanel } from '../devtools/ui/DevToolsPanel';
|
||||
import { getStyleTracker } from '../devtools/StyleTracker';
|
||||
// DiagnosticEngine/ServerConnector/StyleTracker/DevToolsPanel 은 정적 import 하지 않는다 —
|
||||
// 디버그 전용 무거운 모듈은 별도 lazy 번들(devtools.min.js)로 분리되어 initDevToolsAPI() 가
|
||||
// isEnabled() 참일 때만 런타임 <script> 주입으로 로드한다. @since engine-v1.52.0
|
||||
import type { DiagnosticCategory } from '../devtools/types';
|
||||
import {
|
||||
renderItemChildren,
|
||||
@@ -745,6 +747,70 @@ function initComponentEventSystem(G7Core: any): void {
|
||||
logger.log('전역 객체 window.G7Core.componentEvent에 노출됨');
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 런타임 표면을 `window.G7Core.__runtime` 으로 노출 — 편집기 lazy 번들 공유용.
|
||||
*
|
||||
* @since engine-v1.51.0
|
||||
*
|
||||
* 레이아웃 편집기 셸은 별도 번들(`layout-editor.min.js`)로 지연 로드된다(메인 번들 비대화
|
||||
* 회피). 편집기는 코어 런타임의 렌더러/엔진/컨텍스트를 재사용해야 하는데, 이때 **동일 인스턴스
|
||||
* 동일성**이 강제된다:
|
||||
*
|
||||
* - `getInstance()` 싱글톤(TranslationEngine 등)을 편집기 번들이 재번들하면 편집기 사본의
|
||||
* 싱글톤이 메인 번들과 fork → 번역/데이터소스 상태가 어긋난다.
|
||||
* - React Context(TranslationReactContext/ResponsiveContext 등)를 재번들하면 `createContext()`
|
||||
* 가 두 번 실행돼 Context 객체 참조가 달라짐 → 편집기의 `useContext` 가 빈 값을 읽는다.
|
||||
* - `DynamicRenderer` 는 사실상 코어 런타임 전체(ActionDispatcher/DataBindingEngine/모든
|
||||
* Context/sortable)를 끌어오므로, 재번들 시 코어 대부분이 편집기 번들에 중복된다.
|
||||
*
|
||||
* 이를 막기 위해 메인 번들이 이 런타임 표면을 `G7Core.__runtime` 에 노출하고, 편집기 빌드는
|
||||
* `resolve.alias` 로 `../DynamicRenderer` 등 상대 경로를 `G7Core.__runtime` 재export shim 으로
|
||||
* 치환한다(편집기 소스는 무수정). 결과적으로 편집기 번들은 코어 런타임을 0바이트 중복으로
|
||||
* **빌려 쓰고**, 싱글톤/컨텍스트 동일성이 자동 보장된다.
|
||||
*
|
||||
* 메인 `<script>` 는 동기 선행 실행되므로, 편집기 IIFE 가 실행될 때 `__runtime` 은 항상 존재한다.
|
||||
*
|
||||
* @param G7Core - window.G7Core 전역 객체
|
||||
*/
|
||||
function initCoreRuntimeExports(G7Core: any): void {
|
||||
// 이미 노출돼 있으면 멱등 (중복 init 방지)
|
||||
if (G7Core.__runtime) return;
|
||||
|
||||
// 노출 대상 = 편집기가 layout-editor/ 밖으로 직접 import 하는 11개 모듈의 런타임 값만.
|
||||
// (Slot/Transition Provider 는 편집기가 직접 import 하지 않고 메인 번들 renderTemplate 이
|
||||
// wrapping 하므로 __runtime 미포함. DynamicRenderer 내부의 Slot/Transition 의존은 메인 번들
|
||||
// DynamicRenderer 에 이미 번들돼 있어 별도 공유 불필요.)
|
||||
G7Core.__runtime = {
|
||||
// 렌더러 (default export) — 프리뷰 캔버스
|
||||
DynamicRenderer,
|
||||
// 엔진/레지스트리 클래스 (getInstance 싱글톤)
|
||||
ComponentRegistry,
|
||||
TranslationEngine,
|
||||
DataSourceManager,
|
||||
dataSourceManager,
|
||||
DataBindingEngine,
|
||||
dataBindingEngine,
|
||||
ActionDispatcher,
|
||||
// 번역 컨텍스트 (Context 객체 동일성 필수)
|
||||
TranslationReactContext,
|
||||
TranslationProvider,
|
||||
useTranslation,
|
||||
// 반응형 컨텍스트/매니저
|
||||
ResponsiveContext,
|
||||
ResponsiveProvider,
|
||||
useResponsive,
|
||||
responsiveManager,
|
||||
BREAKPOINT_PRESETS,
|
||||
// 인증/로거
|
||||
AuthManager,
|
||||
createLogger,
|
||||
// DevTools 추적 코어 (디버그 lazy 번들이 패널/진단엔진에서 공유) @since engine-v1.52.0
|
||||
G7DevToolsCore,
|
||||
};
|
||||
|
||||
logger.log('전역 객체 window.G7Core.__runtime(코어 런타임 표면)에 노출됨');
|
||||
}
|
||||
|
||||
/**
|
||||
* `G7Core.layoutEditor` 예약 접수함(ready 큐) 초기화 —.
|
||||
*
|
||||
@@ -3032,6 +3098,9 @@ export function initializeG7CoreGlobals(deps: G7CoreDependencies): void {
|
||||
|
||||
const G7Core = (window as any).G7Core;
|
||||
|
||||
// 코어 런타임 표면 노출 (편집기 lazy 번들이 __runtime 재export shim 으로 공유)
|
||||
initCoreRuntimeExports(G7Core);
|
||||
|
||||
// 각 API 그룹 초기화
|
||||
initComponentEventSystem(G7Core);
|
||||
// 레이아웃 편집기 확장점 예약 접수함(편집기 lazy 로드 전 템플릿 등록을 큐에 보존)
|
||||
@@ -3679,6 +3748,86 @@ export function initDevToolsInterface(): void {
|
||||
* G7DevTools.server.dumpState()
|
||||
* ```
|
||||
*/
|
||||
/**
|
||||
* DevTools 디버그 전용 모듈(패널 UI/진단엔진/서버커넥터/스타일추적기)의 lazy 번들 타입.
|
||||
* @since engine-v1.52.0
|
||||
*/
|
||||
interface DevToolsBundle {
|
||||
DiagnosticEngine: any;
|
||||
getServerConnector: () => any;
|
||||
getStyleTracker: () => any;
|
||||
DevToolsPanel: any;
|
||||
}
|
||||
|
||||
let devToolsBundleLoadPromise: Promise<DevToolsBundle> | null = null;
|
||||
|
||||
/**
|
||||
* DevTools lazy 번들(devtools.min.js)을 로드하고 디버그 전용 모듈 4종을 반환한다.
|
||||
*
|
||||
* @since engine-v1.52.0
|
||||
*
|
||||
* 디버그 모드에서만 호출된다. 이미 로드됨(멱등)/진행 중(in-flight 병합)/미로드(1회 주입)
|
||||
* 를 구분한다.
|
||||
*
|
||||
* @returns DevTools 디버그 전용 모듈 묶음
|
||||
* @throws {Error} 스크립트 로드 실패 또는 모듈 미노출 시
|
||||
*/
|
||||
export function loadDevToolsBundle(): Promise<DevToolsBundle> {
|
||||
const G7Core = (window as any).G7Core;
|
||||
|
||||
if (G7Core?.__devtools) {
|
||||
return Promise.resolve(G7Core.__devtools as DevToolsBundle);
|
||||
}
|
||||
if (devToolsBundleLoadPromise) {
|
||||
return devToolsBundleLoadPromise;
|
||||
}
|
||||
|
||||
devToolsBundleLoadPromise = new Promise<DevToolsBundle>((resolve, reject) => {
|
||||
const src = (window as any).G7Config?.coreDevToolsAsset || '/build/core/devtools.min.js';
|
||||
const scriptId = 'g7-devtools-bundle';
|
||||
|
||||
const onReady = () => {
|
||||
const bundle = (window as any).G7Core?.__devtools;
|
||||
if (bundle) {
|
||||
resolve(bundle as DevToolsBundle);
|
||||
} else {
|
||||
reject(new Error('DevTools 번들 로드됨 — 그러나 __devtools 미노출'));
|
||||
}
|
||||
};
|
||||
|
||||
(window as any).G7Core = (window as any).G7Core || {};
|
||||
(window as any).G7Core.__onDevToolsReady = onReady;
|
||||
|
||||
const existing = document.getElementById(scriptId) as HTMLScriptElement | null;
|
||||
if (existing) {
|
||||
existing.addEventListener('load', onReady, { once: true });
|
||||
existing.addEventListener(
|
||||
'error',
|
||||
() => reject(new Error(`DevTools 번들 로드 실패: ${src}`)),
|
||||
{ once: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.id = scriptId;
|
||||
script.src = src;
|
||||
script.async = false;
|
||||
script.addEventListener('load', onReady, { once: true });
|
||||
script.addEventListener(
|
||||
'error',
|
||||
() => {
|
||||
devToolsBundleLoadPromise = null;
|
||||
reject(new Error(`DevTools 번들 로드 실패: ${src}`));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
return devToolsBundleLoadPromise;
|
||||
}
|
||||
|
||||
export function initDevToolsAPI(): void {
|
||||
const G7Core = (window as any).G7Core;
|
||||
if (!G7Core) {
|
||||
@@ -3688,7 +3837,7 @@ export function initDevToolsAPI(): void {
|
||||
const devToolsCore = G7DevToolsCore.getInstance();
|
||||
devToolsCore.initialize();
|
||||
|
||||
// DevTools가 비활성화된 경우 최소 API만 노출
|
||||
// DevTools가 비활성화된 경우 최소 API만 노출 (devtools.min.js 미로드 — 초기 payload 절감)
|
||||
if (!devToolsCore.isEnabled()) {
|
||||
(window as any).G7DevTools = {
|
||||
isEnabled: () => false,
|
||||
@@ -3702,6 +3851,31 @@ export function initDevToolsAPI(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// 디버그 모드 — DevTools lazy 번들 로드 후 전체 API 활성화 (fire-and-forget)
|
||||
void setupDevToolsWhenEnabled(G7Core, devToolsCore);
|
||||
}
|
||||
|
||||
/**
|
||||
* 디버그 모드에서 DevTools lazy 번들을 로드하고 전체 API 를 활성화한다.
|
||||
*
|
||||
* @since engine-v1.52.0
|
||||
*
|
||||
* @param G7Core - window.G7Core 전역 객체
|
||||
* @param devToolsCore - G7DevToolsCore 싱글톤 인스턴스
|
||||
*/
|
||||
async function setupDevToolsWhenEnabled(G7Core: any, devToolsCore: any): Promise<void> {
|
||||
let bundle: DevToolsBundle;
|
||||
try {
|
||||
bundle = await loadDevToolsBundle();
|
||||
} catch (error) {
|
||||
logger.warn('DevTools 번들 로드 실패 — 최소 API 로 폴백:', error);
|
||||
(window as any).G7DevTools = { isEnabled: () => false, enable: () => {} };
|
||||
flushEarlyLogs();
|
||||
return;
|
||||
}
|
||||
|
||||
const { DiagnosticEngine, getServerConnector, getStyleTracker } = bundle;
|
||||
|
||||
// G7Core.devTools 인터페이스 초기화 (renderItemChildren 등에서 사용)
|
||||
initDevToolsInterface();
|
||||
|
||||
@@ -3976,8 +4150,8 @@ export function initDevToolsAPI(): void {
|
||||
|
||||
logger.log('G7DevTools 전역 객체 초기화 완료 (window.G7DevTools)');
|
||||
|
||||
// DevToolsPanel UI 렌더링
|
||||
renderDevToolsPanel();
|
||||
// DevToolsPanel UI 렌더링 (lazy 번들에서 로드한 컴포넌트 전달)
|
||||
renderDevToolsPanel(bundle.DevToolsPanel);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3985,8 +4159,10 @@ export function initDevToolsAPI(): void {
|
||||
*
|
||||
* 별도의 DOM 컨테이너를 생성하여 메인 앱과 독립적으로 렌더링합니다.
|
||||
* 디버그 모드가 활성화된 경우에만 렌더링됩니다.
|
||||
*
|
||||
* @param DevToolsPanel - lazy 번들(devtools.min.js)에서 로드한 패널 컴포넌트
|
||||
*/
|
||||
function renderDevToolsPanel(): void {
|
||||
function renderDevToolsPanel(DevToolsPanel: any): void {
|
||||
// 이미 렌더링되어 있으면 스킵
|
||||
if (document.getElementById('g7-devtools-root')) {
|
||||
logger.log('DevToolsPanel 이미 렌더링됨');
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* ActionDispatcher 런타임 shim — 편집기 번들 전용 (vite.config.editor.js alias 대상)
|
||||
* @since engine-v1.51.0
|
||||
* 값은 window.G7Core.__runtime(싱글톤 동일성 필수), 타입은 원본에서 재export.
|
||||
*/
|
||||
|
||||
import { getCoreRuntime } from './runtime';
|
||||
|
||||
export const ActionDispatcher = getCoreRuntime().ActionDispatcher;
|
||||
|
||||
export type {
|
||||
ActionType,
|
||||
EventType,
|
||||
ActionDefinition,
|
||||
NavigateOptions,
|
||||
IsolatedContextValue,
|
||||
ActionContext,
|
||||
LoadingActionsMap,
|
||||
ActionHandler,
|
||||
ActionResult,
|
||||
} from '../../ActionDispatcher';
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* AuthManager 런타임 shim — 편집기 번들 전용 (vite.config.editor.js alias 대상)
|
||||
* @since engine-v1.51.0
|
||||
* 값은 window.G7Core.__runtime(인증 싱글톤 동일성 필수).
|
||||
* 원본 경로: resources/js/core/auth/AuthManager (본 shim 기준 ../../../auth/AuthManager)
|
||||
*/
|
||||
|
||||
import { getCoreRuntime } from './runtime';
|
||||
|
||||
export const AuthManager = getCoreRuntime().AuthManager;
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* ComponentRegistry 런타임 shim — 편집기 번들 전용 (vite.config.editor.js alias 대상)
|
||||
* @since engine-v1.51.0
|
||||
* 값은 window.G7Core.__runtime(싱글톤 동일성 필수), 타입은 원본에서 재export.
|
||||
*/
|
||||
|
||||
import { getCoreRuntime } from './runtime';
|
||||
|
||||
export const ComponentRegistry = getCoreRuntime().ComponentRegistry;
|
||||
|
||||
export type {
|
||||
ComponentTypeEnum,
|
||||
ComponentMetadata,
|
||||
ComponentManifest,
|
||||
RegistryMap,
|
||||
LoadingState,
|
||||
} from '../../ComponentRegistry';
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* DataBindingEngine 런타임 shim — 편집기 번들 전용 (vite.config.editor.js alias 대상)
|
||||
* @since engine-v1.51.0
|
||||
* 값은 window.G7Core.__runtime(싱글톤 동일성 필수), 타입은 원본에서 재export.
|
||||
*/
|
||||
|
||||
import { getCoreRuntime } from './runtime';
|
||||
|
||||
const rt = getCoreRuntime();
|
||||
|
||||
export const DataBindingEngine = rt.DataBindingEngine;
|
||||
export const dataBindingEngine = rt.dataBindingEngine;
|
||||
|
||||
export type { BindingContext, ResolvedValue, BindingOptions } from '../../DataBindingEngine';
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* DataSourceManager 런타임 shim — 편집기 번들 전용 (vite.config.editor.js alias 대상)
|
||||
* @since engine-v1.51.0
|
||||
* 값은 window.G7Core.__runtime(싱글톤 동일성 필수), 타입은 원본에서 재export.
|
||||
*/
|
||||
|
||||
import { getCoreRuntime } from './runtime';
|
||||
|
||||
const rt = getCoreRuntime();
|
||||
|
||||
export const DataSourceManager = rt.DataSourceManager;
|
||||
export const dataSourceManager = rt.dataSourceManager;
|
||||
|
||||
export type {
|
||||
DataSourceType,
|
||||
WebSocketChannelType,
|
||||
HttpMethod,
|
||||
LoadingStrategy,
|
||||
DataSourceLoadingState,
|
||||
DataSource,
|
||||
DataSourceResult,
|
||||
DataSourceManagerOptions,
|
||||
SampleDataProvider,
|
||||
ConditionContext,
|
||||
} from '../../DataSourceManager';
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* DynamicRenderer 런타임 shim — 편집기 번들 전용 (vite.config.editor.js alias 대상)
|
||||
*
|
||||
* @since engine-v1.51.0
|
||||
*
|
||||
* 편집기 번들은 `../../DynamicRenderer` import 를 이 shim 으로 치환해, 코어 런타임의
|
||||
* DynamicRenderer 를 `window.G7Core.__runtime` 에서 빌려 쓴다(재번들 0바이트, 싱글톤 동일성).
|
||||
* 값은 런타임 전역에서, 타입은 원본 모듈에서 가져온다(타입은 컴파일 시 소거 → 런타임 코드 미포함).
|
||||
*/
|
||||
|
||||
import { getCoreRuntime } from './runtime';
|
||||
|
||||
// 값: 코어 런타임 전역에서 획득 (default export 재현)
|
||||
const DynamicRenderer = getCoreRuntime().DynamicRenderer;
|
||||
|
||||
export default DynamicRenderer;
|
||||
|
||||
// 타입: 원본에서 재export (런타임 코드 없음)
|
||||
export type {
|
||||
ComponentDefinition,
|
||||
DynamicRendererProps,
|
||||
} from '../../DynamicRenderer';
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Logger 런타임 shim — 편집기 번들 전용 (vite.config.editor.js alias 대상)
|
||||
* @since engine-v1.51.0
|
||||
* 값은 window.G7Core.__runtime(로거 팩토리 동일성), 타입은 원본에서 재export.
|
||||
* 원본 경로: resources/js/core/utils/Logger (본 shim 기준 ../../../../utils/Logger)
|
||||
*/
|
||||
|
||||
import { getCoreRuntime } from './runtime';
|
||||
|
||||
export const createLogger = getCoreRuntime().createLogger;
|
||||
|
||||
export type { Logger } from '../../../utils/Logger';
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* ResponsiveContext 런타임 shim — 편집기 번들 전용 (vite.config.editor.js alias 대상)
|
||||
* @since engine-v1.51.0
|
||||
* React Context 객체 동일성 필수 → window.G7Core.__runtime 에서 값 획득.
|
||||
*/
|
||||
|
||||
import { getCoreRuntime } from './runtime';
|
||||
|
||||
const rt = getCoreRuntime();
|
||||
|
||||
export const ResponsiveProvider = rt.ResponsiveProvider;
|
||||
export const useResponsive = rt.useResponsive;
|
||||
export const ResponsiveContext = rt.ResponsiveContext;
|
||||
|
||||
export type { ResponsiveContextValue } from '../../ResponsiveContext';
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* ResponsiveManager 런타임 shim — 편집기 번들 전용 (vite.config.editor.js alias 대상)
|
||||
* @since engine-v1.51.0
|
||||
* 값은 window.G7Core.__runtime(싱글톤 동일성 필수).
|
||||
*/
|
||||
|
||||
import { getCoreRuntime } from './runtime';
|
||||
|
||||
const rt = getCoreRuntime();
|
||||
|
||||
export const responsiveManager = rt.responsiveManager;
|
||||
export const BREAKPOINT_PRESETS = rt.BREAKPOINT_PRESETS;
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* TranslationContext 런타임 shim — 편집기 번들 전용 (vite.config.editor.js alias 대상)
|
||||
* @since engine-v1.51.0
|
||||
* React Context 객체 동일성 필수 → window.G7Core.__runtime 에서 값 획득.
|
||||
*/
|
||||
|
||||
import { getCoreRuntime } from './runtime';
|
||||
|
||||
const rt = getCoreRuntime();
|
||||
|
||||
export const TranslationProvider = rt.TranslationProvider;
|
||||
export const useTranslation = rt.useTranslation;
|
||||
export const TranslationReactContext = rt.TranslationReactContext;
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* TranslationEngine 런타임 shim — 편집기 번들 전용 (vite.config.editor.js alias 대상)
|
||||
* @since engine-v1.51.0
|
||||
* 값은 window.G7Core.__runtime(싱글톤 동일성 필수), 타입은 원본에서 재export.
|
||||
*/
|
||||
|
||||
import { getCoreRuntime } from './runtime';
|
||||
|
||||
export const TranslationEngine = getCoreRuntime().TranslationEngine;
|
||||
|
||||
export type {
|
||||
TranslationDictionary,
|
||||
TranslationParams,
|
||||
TranslationOptions,
|
||||
TranslationContext,
|
||||
} from '../../TranslationEngine';
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 코어 런타임 접근 헬퍼 — 편집기 번들 전용 shim 공용 유틸
|
||||
*
|
||||
* @since engine-v1.51.0
|
||||
*
|
||||
* 편집기 lazy 번들의 런타임 shim 들이 `window.G7Core.__runtime`(메인 번들이 노출)에서
|
||||
* 코어 런타임 값을 안전하게 획득한다. 메인 `<script>` 가 동기 선행 실행되므로 편집기 IIFE
|
||||
* 실행 시점에 `__runtime` 은 항상 존재하지만, 부재 시 명확한 에러로 조기 실패시킨다.
|
||||
*/
|
||||
|
||||
interface CoreRuntime {
|
||||
DynamicRenderer: any;
|
||||
ComponentRegistry: any;
|
||||
TranslationEngine: any;
|
||||
DataSourceManager: any;
|
||||
dataSourceManager: any;
|
||||
DataBindingEngine: any;
|
||||
dataBindingEngine: any;
|
||||
ActionDispatcher: any;
|
||||
TranslationReactContext: any;
|
||||
TranslationProvider: any;
|
||||
useTranslation: any;
|
||||
TransitionProvider: any;
|
||||
useTransitionState: any;
|
||||
ResponsiveContext: any;
|
||||
ResponsiveProvider: any;
|
||||
useResponsive: any;
|
||||
responsiveManager: any;
|
||||
BREAKPOINT_PRESETS: any;
|
||||
SlotProvider: any;
|
||||
useSlot: any;
|
||||
AuthManager: any;
|
||||
createLogger: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* `window.G7Core.__runtime` 반환 — 부재 시 즉시 throw.
|
||||
*
|
||||
* @returns 코어 런타임 표면 객체
|
||||
* @throws {Error} 메인 번들 미로드로 __runtime 부재 시
|
||||
*/
|
||||
export function getCoreRuntime(): CoreRuntime {
|
||||
const runtime = (typeof window !== 'undefined'
|
||||
? (window as any).G7Core?.__runtime
|
||||
: undefined) as CoreRuntime | undefined;
|
||||
|
||||
if (!runtime) {
|
||||
throw new Error(
|
||||
'[layout-editor] window.G7Core.__runtime 이 없습니다. ' +
|
||||
'코어 번들(template-engine.min.js)이 편집기 번들보다 먼저 로드되어야 합니다.'
|
||||
);
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
@@ -42,6 +42,12 @@
|
||||
activeModules: @json($activeModulesMeta ?? []),
|
||||
activePlugins: @json($activePluginsMeta ?? []),
|
||||
appConfig: @json($appConfig ?? []),
|
||||
// 레이아웃 편집기 lazy 번들 URL — `/admin/layout-editor/*` 진입 시에만 런타임
|
||||
// <script> 주입으로 로드된다(초기 접속 payload 에 미포함). filemtime 캐시버스팅,
|
||||
// 미빌드 상태 대비 file_exists 가드.
|
||||
coreEditorAsset: '{{ asset('build/core/layout-editor.min.js') }}?v={{ file_exists(public_path('build/core/layout-editor.min.js')) ? filemtime(public_path('build/core/layout-editor.min.js')) : 0 }}',
|
||||
// DevTools lazy 번들 URL — 디버그 모드에서만 런타임 <script> 주입으로 로드.
|
||||
coreDevToolsAsset: '{{ asset('build/core/devtools.min.js') }}?v={{ file_exists(public_path('build/core/devtools.min.js')) ? filemtime(public_path('build/core/devtools.min.js')) : 0 }}',
|
||||
// 확장(코어/모듈/플러그인) 캐시 버전 SSoT — install/activate/deactivate/update 시 bump.
|
||||
// 클라이언트 fetch (`?v=`) 가 이 값을 동반해야 백엔드 `template.routes.{id}.v{N}`
|
||||
// 키가 새 버전으로 전환되어 routes.json/lang 변경이 즉시 가시화된다.
|
||||
|
||||
@@ -42,6 +42,12 @@
|
||||
activeModules: @json($activeModulesMeta ?? []),
|
||||
activePlugins: @json($activePluginsMeta ?? []),
|
||||
appConfig: @json($appConfig ?? []),
|
||||
// 레이아웃 편집기 lazy 번들 URL — `/admin/layout-editor/*` 진입 시에만 런타임
|
||||
// <script> 주입으로 로드된다(초기 접속 payload 에 미포함). filemtime 캐시버스팅,
|
||||
// 미빌드 상태 대비 file_exists 가드.
|
||||
coreEditorAsset: '{{ asset('build/core/layout-editor.min.js') }}?v={{ file_exists(public_path('build/core/layout-editor.min.js')) ? filemtime(public_path('build/core/layout-editor.min.js')) : 0 }}',
|
||||
// DevTools lazy 번들 URL — 디버그 모드에서만 런타임 <script> 주입으로 로드.
|
||||
coreDevToolsAsset: '{{ asset('build/core/devtools.min.js') }}?v={{ file_exists(public_path('build/core/devtools.min.js')) ? filemtime(public_path('build/core/devtools.min.js')) : 0 }}',
|
||||
// 확장 캐시 버전 SSoT — 클라이언트 fetch (`?v=`) 동반 필수.
|
||||
// 자세한 설명은 admin.blade.php 참조.
|
||||
cache_version: {{ (int) ($extensionCacheVersion ?? 0) }}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Layout Editor — 번들 분리 (lazy 로드) 검증. (engine-v1.51.0)
|
||||
*
|
||||
* 코어 번들(template-engine.min.js)에서 편집기 코드를 분리해 별도 layout-editor.min.js 로
|
||||
* 빌드하고, `/admin/layout-editor/*` 진입 시에만 런타임 <script> 주입으로 로드한다.
|
||||
*
|
||||
* 검증:
|
||||
* - 일반 admin 페이지(대시보드) 접속 → layout-editor.min.js 요청이 발생하지 않는다
|
||||
* (초기 접속 payload 축소의 핵심 — 회귀 시 편집기 코드가 다시 모든 페이지에 실린다)
|
||||
* - 편집기 URL 진입 → layout-editor.min.js 요청 발생 + 편집기 마운트(g7le-toolbar +
|
||||
* g7le-preview-frame). React 사본이 둘이면 "Invalid hook call" 로 마운트 자체가 크래시하므로,
|
||||
* 마운트 성공이 곧 단일 React 인스턴스 + 컨텍스트/싱글톤 동일성(shim __runtime 공유)을 증명한다.
|
||||
*
|
||||
* @scenario bundle_split_lazy_editor
|
||||
* @effects editor_bundle_absent_on_normal_page + editor_bundle_loaded_and_mounted_on_editor_route
|
||||
*/
|
||||
import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth';
|
||||
|
||||
type PwPage = import('@playwright/test').Page;
|
||||
|
||||
const EDITOR_BUNDLE_RE = /layout-editor\.min\.js/;
|
||||
|
||||
/**
|
||||
* page 의 네트워크 요청 중 편집기 번들 요청 URL 을 수집하는 리스너를 설치한다.
|
||||
*
|
||||
* @param page Playwright page
|
||||
* @returns 수집된 편집기 번들 요청 URL 배열(참조 — 이후 계속 채워짐)
|
||||
*/
|
||||
function collectEditorBundleRequests(page: PwPage): string[] {
|
||||
const hits: string[] = [];
|
||||
page.on('request', (req) => {
|
||||
if (EDITOR_BUNDLE_RE.test(req.url())) {
|
||||
hits.push(req.url());
|
||||
}
|
||||
});
|
||||
return hits;
|
||||
}
|
||||
|
||||
test.describe('Layout Editor — 번들 분리 (lazy 로드)', () => {
|
||||
test('일반 admin 페이지는 layout-editor.min.js 를 요청하지 않는다', async ({ page }) => {
|
||||
const token = issueToken('core.templates.layouts.edit');
|
||||
await authenticatePage(page, token);
|
||||
|
||||
const editorRequests = collectEditorBundleRequests(page);
|
||||
|
||||
await page.goto('/admin/dashboard');
|
||||
await page.waitForLoadState('networkidle', { timeout: 30_000 });
|
||||
|
||||
// 초기 접속 payload 에 편집기 번들 미포함 — 회귀 방지 핵심
|
||||
expect(editorRequests, '일반 페이지에서 편집기 번들이 로드됨(회귀)').toEqual([]);
|
||||
});
|
||||
|
||||
test('편집기 URL 진입 시 layout-editor.min.js 를 로드하고 마운트한다', async ({ page }) => {
|
||||
const token = issueToken('core.templates.layouts.edit');
|
||||
await authenticatePage(page, token);
|
||||
|
||||
const editorRequests = collectEditorBundleRequests(page);
|
||||
|
||||
await page.goto('/admin/layout-editor/sirsoft-basic?route=%2F');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
// 편집기 셸 마운트 대기 — 마운트 성공 = 단일 React 인스턴스 + 컨텍스트 동일성 증명
|
||||
await page.waitForSelector('[data-testid="g7le-toolbar"]', { timeout: 30_000 });
|
||||
await page.waitForSelector('[data-testid="g7le-preview-frame"]', { timeout: 30_000 });
|
||||
|
||||
// 편집기 진입 시에는 lazy 번들이 실제로 로드됐어야 한다
|
||||
expect(editorRequests.length, '편집기 번들 요청 미발생').toBeGreaterThan(0);
|
||||
expect(editorRequests[0]).toMatch(EDITOR_BUNDLE_RE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* DevTools — 번들 분리 (lazy/debug 로드) 검증. (engine-v1.52.0)
|
||||
*
|
||||
* 개발자 진단도구(DevTools)는 디버그 모드에서만 쓰이므로 코어 번들에서 분리해 별도
|
||||
* devtools.min.js 로 빌드하고, `initDevToolsAPI()` 가 `isEnabled()` 참일 때만 런타임
|
||||
* <script> 주입으로 로드한다. 디버그 판정 = `G7Config.debug`(app.debug) 또는 서버 환경설정
|
||||
* `settings.advanced.debug_mode` 중 하나라도 참.
|
||||
*
|
||||
* 검증:
|
||||
* - 디버그 상태에 따라 devtools.min.js 로드 여부가 갈린다:
|
||||
* · 디버그 OFF → 요청 없음 (초기 접속 payload 에서 DevTools 611KB 소스 제거)
|
||||
* · 디버그 ON → 번들 로드 + 패널(#g7-devtools-root) 마운트. React 사본이 둘이면
|
||||
* 패널 마운트가 크래시하므로, 마운트 성공이 곧 shim 공유(G7DevToolsCore) + 단일 React 증명.
|
||||
*
|
||||
* 라이브 서버의 디버그 상태를 강제 전환하지 않고, 실제 상태를 감지해 해당 불변식만 단언한다.
|
||||
*
|
||||
* @scenario devtools_bundle_split_lazy
|
||||
* @effects devtools_bundle_lazy_by_debug_state
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/auth';
|
||||
|
||||
type PwPage = import('@playwright/test').Page;
|
||||
|
||||
const DEVTOOLS_BUNDLE_RE = /devtools\.min\.js/;
|
||||
|
||||
function collectDevToolsRequests(page: PwPage): string[] {
|
||||
const hits: string[] = [];
|
||||
page.on('request', (req) => {
|
||||
if (DEVTOOLS_BUNDLE_RE.test(req.url())) {
|
||||
hits.push(req.url());
|
||||
}
|
||||
});
|
||||
return hits;
|
||||
}
|
||||
|
||||
test.describe('DevTools — 번들 분리 (lazy/debug 로드)', () => {
|
||||
test('디버그 상태에 따라 devtools.min.js 로드/미로드가 갈린다', async ({ page }) => {
|
||||
const devtoolsRequests = collectDevToolsRequests(page);
|
||||
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle', { timeout: 30_000 });
|
||||
|
||||
// 페이지의 실제 디버그 활성 여부 감지
|
||||
const debugEnabled = await page.evaluate(() => {
|
||||
try {
|
||||
return Boolean((window as any).G7DevTools?.isEnabled?.());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (debugEnabled) {
|
||||
// 디버그 ON — lazy 번들 로드 + 패널 마운트(단일 React/shim 공유 증명)
|
||||
expect(devtoolsRequests.length, '디버그 ON 인데 DevTools 번들 미로드').toBeGreaterThan(0);
|
||||
expect(devtoolsRequests[0]).toMatch(DEVTOOLS_BUNDLE_RE);
|
||||
// 패널 컨테이너가 DOM 에 마운트됐는지 확인(attached — 접힘 상태라 visible 아님).
|
||||
// 마운트 성공 = React 사본 단일 + G7DevToolsCore shim 공유 정상의 증명.
|
||||
await page.waitForSelector('#g7-devtools-root', { state: 'attached', timeout: 15_000 });
|
||||
} else {
|
||||
// 디버그 OFF — 초기 payload 에서 DevTools 번들 제거(회귀 방지 핵심)
|
||||
expect(devtoolsRequests, '디버그 OFF 인데 DevTools 번들이 로드됨(회귀)').toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* DevTools lazy 번들 빌드 설정 (engine-v1.52.0)
|
||||
*
|
||||
* 개발자 진단도구(패널 UI/진단엔진/서버커넥터/스타일추적기)는 디버그 모드에서만 쓰이므로
|
||||
* 메인 코어 번들에서 분리해 별도 `devtools.min.js` 로 빌드한다. `initDevToolsAPI()` 가
|
||||
* `isEnabled()` 참일 때만 런타임 <script> 주입으로 로드된다.
|
||||
*
|
||||
* 핵심:
|
||||
* - React/ReactDOM/jsx-runtime = external → window.React 등 단일 인스턴스 재사용.
|
||||
* - G7DevToolsCore(추적 코어, 항상 메인 번들에 상주) = resolveId 플러그인으로 shim 치환 →
|
||||
* window.G7Core.__runtime.G7DevToolsCore 공유(싱글톤 동일성 보장).
|
||||
*/
|
||||
|
||||
const coreDir = path.resolve(__dirname, 'resources/js/core');
|
||||
const devtoolsDir = path.resolve(coreDir, 'devtools');
|
||||
const shimDir = path.resolve(devtoolsDir, '__runtime-shims');
|
||||
|
||||
// 원본 절대경로(확장자 제외) → shim 절대경로
|
||||
const targetToShim = new Map([
|
||||
[
|
||||
path.resolve(devtoolsDir, 'G7DevToolsCore').split(path.sep).join('/'),
|
||||
path.resolve(shimDir, 'G7DevToolsCore.ts').split(path.sep).join('/'),
|
||||
],
|
||||
]);
|
||||
|
||||
function normalizeNoExt(id) {
|
||||
return id.replace(/\.(tsx?|jsx?)$/, '').split(path.sep).join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* G7DevToolsCore import 를 __runtime-shims 로 리디렉트하는 Vite 플러그인.
|
||||
* 편집기 번들과 동일 방식(enforce:'pre' + importer 기준 절대경로 매칭).
|
||||
*/
|
||||
function devtoolsRuntimeShimPlugin() {
|
||||
return {
|
||||
name: 'g7-devtools-runtime-shim',
|
||||
enforce: 'pre',
|
||||
resolveId(source, importer) {
|
||||
if (!importer) return null;
|
||||
if (normalizeNoExt(importer).includes('/__runtime-shims/')) return null;
|
||||
if (!source.startsWith('.')) return null;
|
||||
|
||||
const abs = normalizeNoExt(path.resolve(path.dirname(importer), source));
|
||||
return targetToShim.get(abs) || null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [devtoolsRuntimeShimPlugin(), react()],
|
||||
publicDir: false,
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
},
|
||||
build: {
|
||||
outDir: 'public/build/core',
|
||||
emptyOutDir: false,
|
||||
lib: {
|
||||
entry: path.resolve(coreDir, 'devtools-entry.ts'),
|
||||
name: 'G7DevToolsBundle',
|
||||
formats: ['iife'],
|
||||
fileName: () => 'devtools.min.js',
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime'],
|
||||
output: {
|
||||
globals: {
|
||||
react: 'React',
|
||||
'react-dom': 'ReactDOM',
|
||||
'react-dom/client': 'ReactDOM',
|
||||
'react/jsx-runtime': 'ReactJSXRuntime',
|
||||
},
|
||||
exports: 'named',
|
||||
extend: true,
|
||||
},
|
||||
},
|
||||
minify: 'esbuild',
|
||||
sourcemap: true,
|
||||
target: 'es2020',
|
||||
},
|
||||
resolve: {
|
||||
alias: [{ find: '@', replacement: path.resolve(__dirname, 'resources/js') }],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* 레이아웃 편집기 lazy 번들 빌드 설정 (engine-v1.51.0)
|
||||
*
|
||||
* 메인 코어 번들(`vite.config.core.js` → template-engine.min.js)에서 편집기 코드를 제거해
|
||||
* 초기 접속 payload 를 줄이고, 편집기 셸을 별도 `layout-editor.min.js` 로 빌드한다.
|
||||
* `/admin/layout-editor/*` 진입 시에만 런타임 `<script>` 주입으로 로드된다.
|
||||
*
|
||||
* 핵심:
|
||||
* - React/ReactDOM/jsx-runtime = external → window.React 등 (메인 번들이 노출한 단일 인스턴스
|
||||
* 재사용). react/jsx-runtime 을 external 하지 않으면 @vitejs/plugin-react 의 automatic-runtime
|
||||
* JSX import 로 React 가 transitive 재포함돼 "Invalid hook call" 발생.
|
||||
* - 코어 런타임(DynamicRenderer/엔진/컨텍스트) = 커스텀 resolveId 플러그인으로 __runtime-shims
|
||||
* 치환 → window.G7Core.__runtime 에서 빌려 씀(재번들 0바이트, 싱글톤/컨텍스트 동일성 보장).
|
||||
*/
|
||||
|
||||
const coreDir = path.resolve(__dirname, 'resources/js/core');
|
||||
const shimDir = path.resolve(coreDir, 'template-engine/layout-editor/__runtime-shims');
|
||||
|
||||
// 편집기가 layout-editor/ 밖으로 import 하는 코어 런타임 모듈(확장자 제외 절대경로) → shim 절대경로.
|
||||
// resolveId 훅이 "해석된 절대경로" 기준으로 매칭하므로, 편집기가 어느 상대 깊이(../ ../../ ../../../)로
|
||||
// import 하든 단일 규칙으로 전부 shim 으로 치환된다.
|
||||
const RUNTIME_TARGETS = [
|
||||
['template-engine/DynamicRenderer', 'DynamicRenderer'],
|
||||
['template-engine/TranslationEngine', 'TranslationEngine'],
|
||||
['template-engine/TranslationContext', 'TranslationContext'],
|
||||
['template-engine/DataSourceManager', 'DataSourceManager'],
|
||||
['template-engine/DataBindingEngine', 'DataBindingEngine'],
|
||||
['template-engine/ActionDispatcher', 'ActionDispatcher'],
|
||||
['template-engine/ResponsiveManager', 'ResponsiveManager'],
|
||||
['template-engine/ResponsiveContext', 'ResponsiveContext'],
|
||||
['template-engine/ComponentRegistry', 'ComponentRegistry'],
|
||||
['utils/Logger', 'Logger'],
|
||||
['auth/AuthManager', 'AuthManager'],
|
||||
];
|
||||
|
||||
/** 절대경로(확장자 제거) → shim 절대경로 맵 */
|
||||
const targetToShim = new Map(
|
||||
RUNTIME_TARGETS.map(([target, shim]) => [
|
||||
path.resolve(coreDir, target).split(path.sep).join('/'),
|
||||
path.resolve(shimDir, shim + '.ts').split(path.sep).join('/'),
|
||||
])
|
||||
);
|
||||
|
||||
/** 경로에서 .ts/.tsx/.js/.jsx 확장자 제거 후 정규화(/구분자) */
|
||||
function normalizeNoExt(id) {
|
||||
return id.replace(/\.(tsx?|jsx?)$/, '').split(path.sep).join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 런타임 import 를 __runtime-shims 로 리디렉트하는 Vite 플러그인.
|
||||
*
|
||||
* Vite 의 문자열 `resolve.alias` 는 import specifier(상대경로 원문) 기준 매칭이라 상대 깊이가
|
||||
* 제각각인 편집기 import 를 잡지 못한다. 따라서 기본 해석기가 계산한 "절대경로"를 기준으로
|
||||
* 매칭하는 resolveId 훅을 쓴다.
|
||||
*
|
||||
* shim 파일 자신이 원본 경로를 import(값은 없고 `export type` 만 — 컴파일 시 소거)할 수 있으므로,
|
||||
* importer 가 shim 디렉토리 내부면 리디렉트하지 않아 무한 루프를 막는다.
|
||||
*/
|
||||
function coreRuntimeShimPlugin() {
|
||||
return {
|
||||
name: 'g7-core-runtime-shim',
|
||||
enforce: 'pre', // 기본 해석기보다 먼저 실행되어 코어 런타임 import 를 가로챈다
|
||||
async resolveId(source, importer, options) {
|
||||
if (!importer) return null;
|
||||
// shim 자신이 원본을 import(type-only)하는 경우는 리디렉트 제외 (루프 방지)
|
||||
if (normalizeNoExt(importer).includes('/__runtime-shims/')) {
|
||||
return null;
|
||||
}
|
||||
// 상대경로 import 만 대상 (코어 런타임은 편집기에서 상대경로로 참조됨)
|
||||
if (!source.startsWith('.')) return null;
|
||||
|
||||
// importer 기준으로 절대경로 계산 (this.resolve 미사용 — enforce:pre 재진입 회피)
|
||||
const abs = normalizeNoExt(path.resolve(path.dirname(importer), source));
|
||||
const shim = targetToShim.get(abs);
|
||||
return shim || null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [coreRuntimeShimPlugin(), react()],
|
||||
publicDir: false,
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify('production'),
|
||||
},
|
||||
build: {
|
||||
outDir: 'public/build/core',
|
||||
emptyOutDir: false, // template-engine.min.js 와 같은 디렉토리 공유
|
||||
lib: {
|
||||
entry: path.resolve(coreDir, 'layout-editor-entry.ts'),
|
||||
name: 'G7LayoutEditor', // G7Core 와 구분 (실제 연동은 window.G7Core.__LayoutEditorChrome)
|
||||
formats: ['iife'],
|
||||
fileName: () => 'layout-editor.min.js',
|
||||
},
|
||||
rollupOptions: {
|
||||
// React 3종 + jsx-runtime = 메인 번들의 window 전역 재사용 (단일 인스턴스)
|
||||
external: ['react', 'react-dom', 'react-dom/client', 'react/jsx-runtime'],
|
||||
output: {
|
||||
globals: {
|
||||
react: 'React',
|
||||
'react-dom': 'ReactDOM',
|
||||
'react-dom/client': 'ReactDOM',
|
||||
'react/jsx-runtime': 'ReactJSXRuntime',
|
||||
},
|
||||
exports: 'named',
|
||||
extend: true,
|
||||
},
|
||||
},
|
||||
minify: 'esbuild',
|
||||
sourcemap: true,
|
||||
target: 'es2020',
|
||||
},
|
||||
resolve: {
|
||||
alias: [{ find: '@', replacement: path.resolve(__dirname, 'resources/js') }],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user