활성 모듈/플러그인마다 프론트엔드 IIFE JS·CSS 가 개별 HTTP 요청으로 로드되어
확장 수에 비례해 요청이 늘어나는 오버헤드를, 종류별 1개 번들로 서버 병합해 줄인다.
각 확장 IIFE 자가등록 계약(핸들러/리스너/preblocker)은 priority 순 물리 병합으로
실행 순서가 보존되어 그대로 동작한다.
## 백엔드
- 신규 ExtensionBundleService — 정렬(uasort priority)·필터(global)·concat(\n;\n
구분자로 ASI 경계 보호)·소스맵(prod strip / dev rewrite)·version-in-path 디스크
캐시·확장별 fine-grained try/catch(한 확장 실패가 번들 전체를 붕괴시키지 않음)
- AbstractModule/AbstractPlugin::getBuiltAssetAbsolutePaths — _bundled 경로
정확 해석용 절대경로 게터(getAssetFilePath 하드코딩 복제 회피)
- PublicModule/PluginController::serveBundle{Js,Css} + routes/api.php 4개
(/api/{modules,plugins}/bundle.{js,css}?v={version}, fileResponse ETag/304 재사용)
- config/filesystems.php ext-bundles 디스크 + storage/.gitignore + 루트 .gitignore
- 신규 ext-bundles:cleanup 커맨드 + {module,plugin,template}:cache-clear 번들
정리 통합 + dev-dashboard 버튼
## 프론트엔드 (engine-v1.52.0)
- TemplateApp.loadExtensionAssets — 개별 로딩 → bundleUrls 기반 모듈→플러그인
순 번들 로딩. bundleUrls 부재 시 개별 로딩 폴백(회귀 안전)
- ModuleAssetLoader.loadBundle + parseBundleUrlsFromConfig + ExtensionBundleUrls
- TemplateComposer/UserTemplateComposer bundleUrls 뷰변수 + app/admin.blade.php
- core:build 재빌드 산출물(public/build/core/*)
## 버전
- 코어 7.0.1 → 7.0.2 bump. 7.0.1 이 이미 2026-07-03 공개 릴리스이므로 그 위 신규
기능은 +1 bump(new-bump). config/app.php(SSoT) + .env{.example,.testing.example,
.testing} + README 뱃지 + INSTALL 예시 + 루트 CHANGELOG 신규 [7.0.2] 섹션 동기화.
- 확장 g7_version 상향 안 함(>=7.0.0 유지) — 6개 프론트 에셋 확장이 신규 코어
표면을 자기 코드에서 호출하지 않고 개별 서빙 라우트 존치+프론트 폴백으로 코어
7.0.0 에서도 동작 → 실제 의존 없음( 결정).
- 부수 결함 수정: config/app.php 편집으로 활성화된 core-update-targets-coverage
룰이 v1.51.0 에서 추가된 vite.config.{editor,devtools}.js 가 app.update.targets
에 미등재됨을 검출 → 추가. 코어 업데이트 시 편집기/devtools 빌드 설정 갱신 누락
방지(CHANGELOG [7.0.2] Fixed).
## 규정/audit/문서/테스트
- ("확장 번들 병합" 절+파일유형표+E2E경로) / AGENTS.md /
docs/extension/module-assets.md / docs/cheatsheet.md
- coverage.json 4항목 + codified 룰 4종(url-same-origin, concat-separator,
asset-path-getter, clears-template-caches-no-file-delete)
- 루트 CHANGELOG [7.0.2] Added/Fixed / 엔진 CHANGELOG engine-v1.52.0 Added
- Vitest 11(loadBundle + concat 자가등록) + PHPUnit 17(Service 12 + Feature 5)
+ Playwright spec + 시나리오 매니페스트
- 브라우저 실측(g7.dev): 홈/관리자 번들 각 1건·개별 iife 0건, gdpr 인터셉터
정상, 활성화 토글 반영, 장바구니→체크아웃·게시판 글쓰기 정상. g7_2.dev 미적용
대비 확장 요청 11~12→4건, DOMContentLoaded ~843~957→605~741ms(페이로드 무손실)
## 도구 보완
- pre-commit-review 스킬 에 엔진 경로 감지 게이트(5-pre + 보고 5e행) 신설 —
매핑 표에 엔진 파일이 있으면 엔진 CHANGELOG 판정을 강제(이번 초기 누락 재발 방지)
277 lines
11 KiB
PHP
277 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Http\View\Composers;
|
|
|
|
use App\Exceptions\TemplateNotFoundException;
|
|
use App\Extension\ModuleManager;
|
|
use App\Extension\PluginManager;
|
|
use App\Extension\TemplateManager;
|
|
use App\Extension\Traits\ClearsTemplateCaches;
|
|
use App\Http\View\Composers\Traits\CollectsActiveExtensionMeta;
|
|
use App\Http\View\Composers\Traits\CollectsTemplateExternals;
|
|
use App\Services\ModuleSettingsService;
|
|
use App\Services\PluginSettingsService;
|
|
use App\Services\SettingsService;
|
|
use App\Services\TemplateService;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\View\View;
|
|
|
|
/**
|
|
* 사용자 템플릿 View Composer
|
|
*
|
|
* app.blade.php 뷰에 사용자 템플릿 관련 데이터를 바인딩합니다.
|
|
*/
|
|
class UserTemplateComposer
|
|
{
|
|
use CollectsActiveExtensionMeta;
|
|
use CollectsTemplateExternals;
|
|
|
|
/**
|
|
* 서비스 주입
|
|
*
|
|
* @param TemplateService $templateService 템플릿 서비스
|
|
* @param SettingsService $settingsService 코어 설정 서비스
|
|
* @param ModuleSettingsService $moduleSettingsService 모듈 설정 서비스
|
|
* @param PluginSettingsService $pluginSettingsService 플러그인 설정 서비스
|
|
* @param ModuleManager $moduleManager 모듈 매니저
|
|
* @param PluginManager $pluginManager 플러그인 매니저
|
|
* @param TemplateManager $templateManager 템플릿 매니저
|
|
*/
|
|
public function __construct(
|
|
private TemplateService $templateService,
|
|
private SettingsService $settingsService,
|
|
private ModuleSettingsService $moduleSettingsService,
|
|
private PluginSettingsService $pluginSettingsService,
|
|
private ModuleManager $moduleManager,
|
|
private PluginManager $pluginManager,
|
|
private TemplateManager $templateManager
|
|
) {}
|
|
|
|
/**
|
|
* 뷰에 데이터 바인딩
|
|
*
|
|
* @param View $view 뷰 인스턴스
|
|
*/
|
|
public function compose(View $view): void
|
|
{
|
|
try {
|
|
$activeTemplate = $this->templateService->getActiveTemplateIdentifier('user');
|
|
} catch (TemplateNotFoundException $e) {
|
|
$activeTemplate = null;
|
|
}
|
|
|
|
// 프론트엔드 전역 변수로 사용할 설정 조회
|
|
try {
|
|
$frontendSettings = $this->settingsService->getFrontendSettings();
|
|
} catch (\Exception $e) {
|
|
$frontendSettings = [];
|
|
}
|
|
|
|
// 활성화된 플러그인 설정 조회
|
|
try {
|
|
$pluginSettings = $this->pluginSettingsService->getAllActiveSettings();
|
|
} catch (\Exception $e) {
|
|
$pluginSettings = [];
|
|
}
|
|
|
|
// 활성화된 모듈 설정 조회 (frontend_schema 기반 필터링 적용)
|
|
try {
|
|
$moduleSettings = $this->moduleSettingsService->getAllActiveSettings();
|
|
} catch (\Exception $e) {
|
|
$moduleSettings = [];
|
|
}
|
|
|
|
// 활성화된 모듈의 프론트엔드 에셋 정보 수집
|
|
$moduleAssets = $this->collectModuleAssets();
|
|
|
|
// 활성화된 플러그인의 프론트엔드 에셋 정보 수집
|
|
$pluginAssets = $this->collectPluginAssets();
|
|
|
|
// 활성 확장(모듈/플러그인) 메타 — 레이아웃 편집기 SSoT
|
|
// 기존 modules/plugins 키는 hasSettings() 필터로 활성 전수가 아님
|
|
$activeModulesMeta = $this->collectActiveModulesMeta();
|
|
$activePluginsMeta = $this->collectActivePluginsMeta();
|
|
|
|
// 프론트엔드에 노출할 앱 config 값 조회
|
|
try {
|
|
$appConfig = $this->settingsService->getAppConfigForFrontend();
|
|
} catch (\Exception $e) {
|
|
$appConfig = [];
|
|
}
|
|
|
|
// 템플릿의 외부 리소스 정보 수집
|
|
$templateExternals = $this->collectTemplateExternals($activeTemplate);
|
|
|
|
// 확장 기능 캐시 버전 (브라우저 캐시 무효화용)
|
|
$extensionCacheVersion = ClearsTemplateCaches::getExtensionCacheVersion();
|
|
|
|
// 확장 프론트엔드 병합 번들 URL (상시 ON — 활성 에셋이 없으면 null)
|
|
$bundleUrls = $this->buildExtensionBundleUrls($moduleAssets, $pluginAssets, $extensionCacheVersion);
|
|
|
|
$view->with('activeUserTemplate', $activeTemplate);
|
|
$view->with('extensionCacheVersion', $extensionCacheVersion);
|
|
$view->with('frontendSettings', $frontendSettings);
|
|
$view->with('pluginSettings', $pluginSettings);
|
|
$view->with('moduleSettings', $moduleSettings);
|
|
$view->with('moduleAssets', $moduleAssets);
|
|
$view->with('pluginAssets', $pluginAssets);
|
|
$view->with('bundleUrls', $bundleUrls);
|
|
$view->with('activeModulesMeta', $activeModulesMeta);
|
|
$view->with('activePluginsMeta', $activePluginsMeta);
|
|
$view->with('appConfig', $appConfig);
|
|
$view->with('templateExternals', $templateExternals);
|
|
}
|
|
|
|
/**
|
|
* 확장 프론트엔드 병합 번들 URL을 계산합니다.
|
|
*
|
|
* 활성 global 에셋(JS/CSS)이 하나라도 있는 타입만 해당 번들 URL을 채우고,
|
|
* 없으면 null을 두어 프론트가 로드를 스킵하게 한다. URL은 same-origin
|
|
* (`/api/...`)이어야 gdpr preblocker 의 same-origin 통과 규칙에 자기 차단되지
|
|
* 않는다(CDN 호스팅 금지).
|
|
*
|
|
* @param array $moduleAssets 수집된 모듈 개별 에셋 맵
|
|
* @param array $pluginAssets 수집된 플러그인 개별 에셋 맵
|
|
* @param int $version 확장 캐시 버전
|
|
* @return array{moduleJs: ?string, moduleCss: ?string, pluginJs: ?string, pluginCss: ?string}
|
|
*/
|
|
private function buildExtensionBundleUrls(array $moduleAssets, array $pluginAssets, int $version): array
|
|
{
|
|
$hasJs = static fn (array $assets): bool => ! empty(array_filter($assets, fn ($a) => ! empty($a['js'])));
|
|
$hasCss = static fn (array $assets): bool => ! empty(array_filter($assets, fn ($a) => ! empty($a['css'])));
|
|
|
|
return [
|
|
'moduleJs' => $hasJs($moduleAssets) ? "/api/modules/bundle.js?v={$version}" : null,
|
|
'moduleCss' => $hasCss($moduleAssets) ? "/api/modules/bundle.css?v={$version}" : null,
|
|
'pluginJs' => $hasJs($pluginAssets) ? "/api/plugins/bundle.js?v={$version}" : null,
|
|
'pluginCss' => $hasCss($pluginAssets) ? "/api/plugins/bundle.css?v={$version}" : null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 활성화된 모듈의 프론트엔드 에셋 정보를 수집합니다.
|
|
*
|
|
* @return array<string, array{js?: string, css?: string, priority: int, external?: array}>
|
|
*/
|
|
private function collectModuleAssets(): array
|
|
{
|
|
$assets = [];
|
|
|
|
try {
|
|
// ModuleManager에서 활성화된 모듈 목록 조회
|
|
$activeModules = $this->moduleManager->getActiveModules();
|
|
|
|
// 캐시 버전 조회 (브라우저 캐시 무효화용)
|
|
$cacheVersion = ClearsTemplateCaches::getExtensionCacheVersion();
|
|
|
|
foreach ($activeModules as $identifier => $module) {
|
|
// 모듈에 에셋이 있는지 확인
|
|
if (! $module->hasAssets()) {
|
|
continue;
|
|
}
|
|
|
|
$builtPaths = $module->getBuiltAssetPaths();
|
|
$loadingConfig = $module->getAssetLoadingConfig();
|
|
$assetConfig = $module->getAssets();
|
|
|
|
// global 전략인 경우에만 수집 (layout, lazy는 레이아웃에서 처리)
|
|
if ($loadingConfig['strategy'] !== 'global') {
|
|
continue;
|
|
}
|
|
|
|
$moduleAsset = [
|
|
'priority' => $loadingConfig['priority'],
|
|
];
|
|
|
|
// JS 빌드 경로 (캐시 버전 파라미터 추가)
|
|
if (! empty($builtPaths['js'])) {
|
|
$moduleAsset['js'] = "/api/modules/assets/{$identifier}/".$builtPaths['js']."?v={$cacheVersion}";
|
|
}
|
|
|
|
// CSS 빌드 경로 (캐시 버전 파라미터 추가)
|
|
if (! empty($builtPaths['css'])) {
|
|
$moduleAsset['css'] = "/api/modules/assets/{$identifier}/".$builtPaths['css']."?v={$cacheVersion}";
|
|
}
|
|
|
|
// 외부 스크립트 (조건부 로드용)
|
|
if (! empty($assetConfig['external'])) {
|
|
$moduleAsset['external'] = $assetConfig['external'];
|
|
}
|
|
|
|
$assets[$identifier] = $moduleAsset;
|
|
}
|
|
|
|
// 우선순위 기준 정렬 (낮을수록 먼저)
|
|
uasort($assets, fn ($a, $b) => $a['priority'] <=> $b['priority']);
|
|
} catch (\Exception $e) {
|
|
// 에러 발생 시 빈 배열 반환 (에셋 로드 실패해도 앱 진행)
|
|
Log::warning('Failed to collect module assets: '.$e->getMessage());
|
|
}
|
|
|
|
return $assets;
|
|
}
|
|
|
|
/**
|
|
* 활성화된 플러그인의 프론트엔드 에셋 정보를 수집합니다.
|
|
*
|
|
* @return array<string, array{js?: string, css?: string, priority: int, external?: array}>
|
|
*/
|
|
private function collectPluginAssets(): array
|
|
{
|
|
$assets = [];
|
|
|
|
try {
|
|
// PluginManager에서 활성화된 플러그인 목록 조회
|
|
$activePlugins = $this->pluginManager->getActivePlugins();
|
|
|
|
// 캐시 버전 조회 (브라우저 캐시 무효화용)
|
|
$cacheVersion = ClearsTemplateCaches::getExtensionCacheVersion();
|
|
|
|
foreach ($activePlugins as $identifier => $plugin) {
|
|
// 플러그인에 에셋이 있는지 확인
|
|
if (! $plugin->hasAssets()) {
|
|
continue;
|
|
}
|
|
|
|
$builtPaths = $plugin->getBuiltAssetPaths();
|
|
$loadingConfig = $plugin->getAssetLoadingConfig();
|
|
$assetConfig = $plugin->getAssets();
|
|
|
|
// global 전략인 경우에만 수집 (layout, lazy는 레이아웃에서 처리)
|
|
if ($loadingConfig['strategy'] !== 'global') {
|
|
continue;
|
|
}
|
|
|
|
$pluginAsset = [
|
|
'priority' => $loadingConfig['priority'],
|
|
];
|
|
|
|
// JS 빌드 경로 (캐시 버전 파라미터 추가)
|
|
if (! empty($builtPaths['js'])) {
|
|
$pluginAsset['js'] = "/api/plugins/assets/{$identifier}/".$builtPaths['js']."?v={$cacheVersion}";
|
|
}
|
|
|
|
// CSS 빌드 경로 (캐시 버전 파라미터 추가)
|
|
if (! empty($builtPaths['css'])) {
|
|
$pluginAsset['css'] = "/api/plugins/assets/{$identifier}/".$builtPaths['css']."?v={$cacheVersion}";
|
|
}
|
|
|
|
// 외부 스크립트 (조건부 로드용)
|
|
if (! empty($assetConfig['external'])) {
|
|
$pluginAsset['external'] = $assetConfig['external'];
|
|
}
|
|
|
|
$assets[$identifier] = $pluginAsset;
|
|
}
|
|
|
|
// 우선순위 기준 정렬 (낮을수록 먼저)
|
|
uasort($assets, fn ($a, $b) => $a['priority'] <=> $b['priority']);
|
|
} catch (\Exception $e) {
|
|
// 에러 발생 시 빈 배열 반환 (에셋 로드 실패해도 앱 진행)
|
|
Log::warning('Failed to collect plugin assets: '.$e->getMessage());
|
|
}
|
|
|
|
return $assets;
|
|
}
|
|
}
|