Files
Gnuboard7/app/Http/View/Composers/UserTemplateComposer.php
T
HeuJung a958443d69 feat(core): 모듈/플러그인 프론트엔드 에셋 서버측 번들 병합
활성 모듈/플러그인마다 프론트엔드 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 판정을 강제(이번 초기 누락 재발 방지)
2026-07-05 21:57:39 +09:00

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;
}
}