From f267f21ea64ad8a34f7f581bee3734eb4416a96b Mon Sep 17 00:00:00 2001 From: HeuJung Date: Sat, 4 Jul 2026 22:19:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(core):=20=EB=AA=A8=EB=93=88/=ED=94=8C?= =?UTF-8?q?=EB=9F=AC=EA=B7=B8=EC=9D=B8=20=ED=94=84=EB=A1=A0=ED=8A=B8?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=EC=97=90=EC=85=8B=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EC=B8=A1=20=EB=B2=88=EB=93=A4=20=EB=B3=91=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 활성 모듈/플러그인마다 프론트엔드 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/*) ## 버전 동기화 판정 ( 결정) 코어 add-only(개별 서빙 라우트 존치 + 프론트 폴백). 6개 프론트 에셋 확장이 신규 코어 표면을 자기 코드에서 호출하지 않아 코어 7.0.0 에서도 동작 → 실제 의존 없음. 확장 g7_version 상향 안 함(>=7.0.0 유지). 코어 버전 무변경(7.0.1 accumulate). ## 규정/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.1] Added / 엔진 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 판정을 강제(이번 초기 누락 재발 방지) --- .gitignore | 3 + AGENTS.md | 12 + CHANGELOG.md | 4 + .../CleanupExtensionBundlesCommand.php | 43 ++ .../Module/ClearModuleCacheCommand.php | 7 +- .../Plugin/ClearPluginCacheCommand.php | 7 +- .../Template/ClearTemplateCacheCommand.php | 7 +- app/Extension/AbstractModule.php | 23 + app/Extension/AbstractPlugin.php | 24 + .../Api/Public/PublicModuleController.php | 46 +- .../Api/Public/PublicPluginController.php | 45 +- app/Http/View/Composers/TemplateComposer.php | 30 ++ .../View/Composers/UserTemplateComposer.php | 30 ++ app/Services/ExtensionBundleService.php | 475 ++++++++++++++++++ config/filesystems.php | 10 + docs/cheatsheet.md | 2 + docs/extension/module-assets.md | 56 +++ public/build/core/devtools.min.js.map | 2 +- public/build/core/layout-editor.min.js | 2 +- public/build/core/layout-editor.min.js.map | 2 +- public/build/core/template-engine.min.js | 44 +- public/build/core/template-engine.min.js.map | 2 +- resources/js/core/TemplateApp.ts | 63 ++- .../js/core/modules/ModuleAssetLoader.ts | 151 ++++++ .../__tests__/ModuleAssetLoader.test.ts | 108 +++- resources/js/core/modules/index.ts | 3 +- .../js/core/template-engine/CHANGELOG.md | 13 + resources/views/admin.blade.php | 1 + resources/views/app.blade.php | 1 + resources/views/dev-dashboard.blade.php | 4 + routes/api.php | 12 + .../Api/Public/ExtensionBundleServingTest.php | 119 +++++ .../specs/extension-bundle-loading.spec.ts | 78 +++ .../Services/ExtensionBundleServiceTest.php | 307 +++++++++++ tests/scenarios/extension-bundle-loading.yaml | 71 +++ 35 files changed, 1754 insertions(+), 53 deletions(-) create mode 100644 app/Console/Commands/CleanupExtensionBundlesCommand.php create mode 100644 app/Services/ExtensionBundleService.php create mode 100644 tests/Feature/Api/Public/ExtensionBundleServingTest.php create mode 100644 tests/Playwright/specs/extension-bundle-loading.spec.ts create mode 100644 tests/Unit/Services/ExtensionBundleServiceTest.php create mode 100644 tests/scenarios/extension-bundle-loading.yaml diff --git a/.gitignore b/.gitignore index ee76dad5..b2a79076 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,9 @@ node_modules/ # Settings JSON files (환경별 설정) /storage/app/settings/ +# 확장 프론트엔드 병합 번들 캐시 (version-in-path, 런타임 생성) +/storage/app/ext-bundles/ + # Bootstrap cache (auto-generated) /bootstrap/cache/* !/bootstrap/cache/.gitkeep diff --git a/AGENTS.md b/AGENTS.md index 98717a21..4ffdec6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -788,6 +788,18 @@ php artisan plugin:update sirsoft-payment --force 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/` 로 치환한다. +### 확장 번들 병합 (서버측 concat) + +활성 모듈/플러그인의 프론트엔드 IIFE JS·CSS 는 타입별로 서버에서 하나의 번들로 병합해 서빙한다(`/api/{modules,plugins}/bundle.{js,css}?v={version}`). `ExtensionBundleService` 가 정렬·필터·concat·캐시를 전담하고, 프론트는 `window.G7Config.bundleUrls` 를 읽어 모듈 번들 → 플러그인 번들 순으로 로드한다. 병합 규율: + +- priority 순서는 선언형 — 실행 순서는 오직 manifest `loading.priority` 오름차순(`uasort`). 특정 확장 이름을 지목하는 분기를 두지 않는다. +- IIFE 사이는 `\n;\n`(JS)/`\n`(CSS) 로 잇는다. 미사용 시 ASI 경계가 깨져 번들 전체 파싱 에러가 난다. +- 소스맵은 prod strip, dev 는 개별 에셋 서빙 절대 URL 로 rewrite. 개별 에셋 서빙 라우트(`*.map` 포함)는 존치한다. +- 번들 URL 은 반드시 same-origin(`/api/...`). 외부 origin/CDN·protocol-relative 는 gdpr preblocker 에 자기 차단된다. +- 확장 에셋 절대경로는 `getBuiltAssetAbsolutePaths()`(=`getModulePath()`/`getPluginPath()`) 만 쓴다. `base_path("modules"|"plugins")` 직접 조립은 `_bundled` 경로 오해석 → 빈 번들. +- concat 루프는 확장별 try/catch — 실패 확장만 skip 하고 나머지 병합을 지속한다. +- 번들 파일명에 확장 캐시 버전을 포함(`{type}.{version}.{js,css}`). 조합 변경 시 version bump → 새 파일명 → 자동 재생성. 구파일 GC 는 `ext-bundles:cleanup` + `{module,plugin,template}:cache-clear` 가 담당한다. prod 은 version-in-path 디스크 캐시, 비프로덕션은 매 요청 concat. + ### 빌드 명령어 (Artisan) ```bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 05784da4..40cf4305 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ## [7.0.1] - 2026-07-03 +### Added + +- 설치된 모듈·플러그인이 늘어날수록 화면마다 개별로 내려받던 확장 스크립트·스타일 요청을 종류별로 하나로 묶어 내려받도록 개선해, 확장이 많은 사이트의 초기 로딩 요청 수를 크게 줄였습니다. 확장을 설치·활성화·비활성화하면 묶음이 자동으로 다시 만들어져 항상 현재 활성 상태와 일치합니다. + ### Changed - 첫 화면 접속 시 내려받는 코어 스크립트 용량을 절반 이하로 줄여 초기 로딩을 개선했습니다. 레이아웃 편집기와 개발자 진단도구를 코어에서 분리해, 편집기는 편집 화면에 들어갈 때만, 진단도구는 디버그 모드를 켰을 때만 내려받도록 했습니다. 일반 사용자는 평소 쓰지 않는 편집기·진단도구 코드를 더 이상 받지 않습니다. diff --git a/app/Console/Commands/CleanupExtensionBundlesCommand.php b/app/Console/Commands/CleanupExtensionBundlesCommand.php new file mode 100644 index 00000000..cf1ac59b --- /dev/null +++ b/app/Console/Commands/CleanupExtensionBundlesCommand.php @@ -0,0 +1,43 @@ +getCurrentVersion(); + $deleted = $service->cleanupStaleBundles($currentVersion); + + $this->info("오래된 확장 번들 {$deleted}건이 삭제되었습니다. (현재 version: {$currentVersion})"); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/Module/ClearModuleCacheCommand.php b/app/Console/Commands/Module/ClearModuleCacheCommand.php index 1fad57b9..6c2d5362 100644 --- a/app/Console/Commands/Module/ClearModuleCacheCommand.php +++ b/app/Console/Commands/Module/ClearModuleCacheCommand.php @@ -5,6 +5,7 @@ namespace App\Console\Commands\Module; use App\Contracts\Extension\CacheInterface; use App\Contracts\Repositories\ModuleRepositoryInterface; use App\Extension\ModuleManager; +use App\Services\ExtensionBundleService; use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; @@ -27,7 +28,8 @@ class ClearModuleCacheCommand extends Command public function __construct( private ModuleManager $moduleManager, private ModuleRepositoryInterface $moduleRepository, - private CacheInterface $cache + private CacheInterface $cache, + private ExtensionBundleService $bundleService ) { parent::__construct(); } @@ -86,6 +88,9 @@ class ClearModuleCacheCommand extends Command $clearedCount += $this->clearModuleCache($module->getIdentifier()); } + // 모듈 프론트엔드 병합 번들 파일 삭제 (캐시 키 forget 만으로는 미삭제) + $clearedCount += $this->bundleService->clearBundles('module'); + $this->info('✅ '.__('modules.commands.cache_clear.success_all', ['count' => $clearedCount])); } diff --git a/app/Console/Commands/Plugin/ClearPluginCacheCommand.php b/app/Console/Commands/Plugin/ClearPluginCacheCommand.php index 1fd77bb3..0dd0aa42 100644 --- a/app/Console/Commands/Plugin/ClearPluginCacheCommand.php +++ b/app/Console/Commands/Plugin/ClearPluginCacheCommand.php @@ -5,6 +5,7 @@ namespace App\Console\Commands\Plugin; use App\Contracts\Extension\CacheInterface; use App\Contracts\Repositories\PluginRepositoryInterface; use App\Extension\PluginManager; +use App\Services\ExtensionBundleService; use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; @@ -27,7 +28,8 @@ class ClearPluginCacheCommand extends Command public function __construct( private PluginManager $pluginManager, private PluginRepositoryInterface $pluginRepository, - private CacheInterface $cache + private CacheInterface $cache, + private ExtensionBundleService $bundleService ) { parent::__construct(); } @@ -86,6 +88,9 @@ class ClearPluginCacheCommand extends Command $clearedCount += $this->clearPluginCache($plugin->getIdentifier()); } + // 플러그인 프론트엔드 병합 번들 파일 삭제 (캐시 키 forget 만으로는 미삭제) + $clearedCount += $this->bundleService->clearBundles('plugin'); + $this->info('✅ '.__('plugins.commands.cache_clear.success_all', ['count' => $clearedCount])); } diff --git a/app/Console/Commands/Template/ClearTemplateCacheCommand.php b/app/Console/Commands/Template/ClearTemplateCacheCommand.php index 8c8a8632..ca13ec43 100644 --- a/app/Console/Commands/Template/ClearTemplateCacheCommand.php +++ b/app/Console/Commands/Template/ClearTemplateCacheCommand.php @@ -6,6 +6,7 @@ use App\Contracts\Extension\CacheInterface; use App\Extension\TemplateManager; use App\Models\Template; use App\Models\TemplateLayout; +use App\Services\ExtensionBundleService; use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; @@ -27,7 +28,8 @@ class ClearTemplateCacheCommand extends Command */ public function __construct( private TemplateManager $templateManager, - private CacheInterface $cache + private CacheInterface $cache, + private ExtensionBundleService $bundleService ) { parent::__construct(); } @@ -153,6 +155,9 @@ class ClearTemplateCacheCommand extends Command $this->cache->forget('templates.active.user'); $clearedCount += 2; + // 5. 확장 프론트엔드 병합 번들 파일 전체 삭제 (템플릿 캐시 정리는 페이지 전면 갱신) + $clearedCount += $this->bundleService->clearBundles(); + $this->info('✅ '.__('templates.commands.cache_clear.success_all', [ 'count' => $clearedCount, ])); diff --git a/app/Extension/AbstractModule.php b/app/Extension/AbstractModule.php index 8a72e698..c7a3f532 100644 --- a/app/Extension/AbstractModule.php +++ b/app/Extension/AbstractModule.php @@ -1190,6 +1190,29 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte return $result; } + /** + * 빌드된 에셋의 절대 파일 경로를 반환합니다. + * + * `getBuiltAssetPaths()` 는 module.json 의 상대 output 경로를 돌려주므로 + * 파일을 실제로 읽으려면 모듈 루트(`getModulePath()`: 활성 dir 또는 `_bundled` + * 실제 위치)를 앞에 붙여야 한다. 서버측 번들 병합(ExtensionBundleService)이 + * `getAssetFilePath()` 의 `base_path("modules/{id}/...")` 하드코딩을 복제하지 + * 않고 `_bundled` 확장에서도 정확한 경로를 얻도록 이 게터를 SSoT 로 쓴다. + * + * @return array 빌드된 에셋 절대 경로 배열 ['js' => '...', 'css' => '...'] + */ + public function getBuiltAssetAbsolutePaths(): array + { + $relative = $this->getBuiltAssetPaths(); + $result = []; + + foreach ($relative as $kind => $output) { + $result[$kind] = $this->getModulePath().'/'.$output; + } + + return $result; + } + /** * 모듈 스토리지 드라이버 인스턴스 반환 * diff --git a/app/Extension/AbstractPlugin.php b/app/Extension/AbstractPlugin.php index 5dd4ad10..1e76a1b8 100644 --- a/app/Extension/AbstractPlugin.php +++ b/app/Extension/AbstractPlugin.php @@ -1011,6 +1011,30 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte return $result; } + /** + * 빌드된 에셋의 절대 파일 경로를 반환합니다. + * + * `getBuiltAssetPaths()` 는 plugin.json 의 상대 output 경로를 돌려주므로 + * 파일을 실제로 읽으려면 플러그인 루트(`getPluginPath()`: 활성 dir 또는 + * `_bundled` 실제 위치)를 앞에 붙여야 한다. 서버측 번들 병합 + * (ExtensionBundleService)이 `getAssetFilePath()` 의 + * `base_path("plugins/{id}/...")` 하드코딩을 복제하지 않고 `_bundled` + * 확장에서도 정확한 경로를 얻도록 이 게터를 SSoT 로 쓴다. + * + * @return array 빌드된 에셋 절대 경로 배열 ['js' => '...', 'css' => '...'] + */ + public function getBuiltAssetAbsolutePaths(): array + { + $relative = $this->getBuiltAssetPaths(); + $result = []; + + foreach ($relative as $kind => $output) { + $result[$kind] = $this->getPluginPath().'/'.$output; + } + + return $result; + } + /** * 플러그인이 설정 페이지를 가지고 있는지 확인합니다. * diff --git a/app/Http/Controllers/Api/Public/PublicModuleController.php b/app/Http/Controllers/Api/Public/PublicModuleController.php index cf46e435..85de88f1 100644 --- a/app/Http/Controllers/Api/Public/PublicModuleController.php +++ b/app/Http/Controllers/Api/Public/PublicModuleController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Public; use App\Http\Controllers\Api\Base\PublicBaseController; use App\Http\Requests\Public\Module\ServeModuleAssetRequest; +use App\Services\ExtensionBundleService; use App\Services\ModuleService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; @@ -17,11 +18,54 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse; class PublicModuleController extends PublicBaseController { public function __construct( - private readonly ModuleService $moduleService + private readonly ModuleService $moduleService, + private readonly ExtensionBundleService $bundleService ) { parent::__construct(); } + /** + * 활성 모듈 프론트엔드 IIFE 병합 번들(JS)을 서빙합니다. + * + * 활성 global 모듈 에셋이 없으면 빈 200 응답(text/javascript)을 반환한다 + * (프론트는 빈 스크립트 로드로 무해). 그 외에는 병합 파일을 fileResponse 로 + * 서빙(ETag/304/환경별 Cache-Control 재사용). + * + * @return BinaryFileResponse|Response 병합 JS 파일 응답 또는 빈 응답 + */ + public function serveBundleJs(): BinaryFileResponse|Response + { + $this->logApiUsage('modules.bundle', ['kind' => 'js']); + + $version = $this->bundleService->getCurrentVersion(); + $path = $this->bundleService->getBundleFilePath('module', 'js', $version); + + if ($path === '') { + return response('', 200)->header('Content-Type', 'text/javascript'); + } + + return $this->fileResponse($path, 'text/javascript', 31536000); + } + + /** + * 활성 모듈 프론트엔드 병합 번들(CSS)을 서빙합니다. + * + * @return BinaryFileResponse|Response 병합 CSS 파일 응답 또는 빈 응답 + */ + public function serveBundleCss(): BinaryFileResponse|Response + { + $this->logApiUsage('modules.bundle', ['kind' => 'css']); + + $version = $this->bundleService->getCurrentVersion(); + $path = $this->bundleService->getBundleFilePath('module', 'css', $version); + + if ($path === '') { + return response('', 200)->header('Content-Type', 'text/css'); + } + + return $this->fileResponse($path, 'text/css', 31536000); + } + /** * 모듈 에셋 서빙 * diff --git a/app/Http/Controllers/Api/Public/PublicPluginController.php b/app/Http/Controllers/Api/Public/PublicPluginController.php index bb3e0f7b..b187f984 100644 --- a/app/Http/Controllers/Api/Public/PublicPluginController.php +++ b/app/Http/Controllers/Api/Public/PublicPluginController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Public; use App\Http\Controllers\Api\Base\PublicBaseController; use App\Http\Requests\Public\Plugin\ServePluginAssetRequest; +use App\Services\ExtensionBundleService; use App\Services\PluginService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; @@ -17,11 +18,53 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse; class PublicPluginController extends PublicBaseController { public function __construct( - private readonly PluginService $pluginService + private readonly PluginService $pluginService, + private readonly ExtensionBundleService $bundleService ) { parent::__construct(); } + /** + * 활성 플러그인 프론트엔드 IIFE 병합 번들(JS)을 서빙합니다. + * + * 활성 global 플러그인 에셋이 없으면 빈 200 응답(text/javascript)을 반환한다. + * 그 외에는 병합 파일을 fileResponse 로 서빙(ETag/304/환경별 Cache-Control 재사용). + * + * @return BinaryFileResponse|Response 병합 JS 파일 응답 또는 빈 응답 + */ + public function serveBundleJs(): BinaryFileResponse|Response + { + $this->logApiUsage('plugins.bundle', ['kind' => 'js']); + + $version = $this->bundleService->getCurrentVersion(); + $path = $this->bundleService->getBundleFilePath('plugin', 'js', $version); + + if ($path === '') { + return response('', 200)->header('Content-Type', 'text/javascript'); + } + + return $this->fileResponse($path, 'text/javascript', 31536000); + } + + /** + * 활성 플러그인 프론트엔드 병합 번들(CSS)을 서빙합니다. + * + * @return BinaryFileResponse|Response 병합 CSS 파일 응답 또는 빈 응답 + */ + public function serveBundleCss(): BinaryFileResponse|Response + { + $this->logApiUsage('plugins.bundle', ['kind' => 'css']); + + $version = $this->bundleService->getCurrentVersion(); + $path = $this->bundleService->getBundleFilePath('plugin', 'css', $version); + + if ($path === '') { + return response('', 200)->header('Content-Type', 'text/css'); + } + + return $this->fileResponse($path, 'text/css', 31536000); + } + /** * 플러그인 에셋 서빙 * diff --git a/app/Http/View/Composers/TemplateComposer.php b/app/Http/View/Composers/TemplateComposer.php index ff7c9320..5d565fb3 100644 --- a/app/Http/View/Composers/TemplateComposer.php +++ b/app/Http/View/Composers/TemplateComposer.php @@ -99,6 +99,9 @@ class TemplateComposer // 확장 기능 캐시 버전 (브라우저 캐시 무효화용) $extensionCacheVersion = ClearsTemplateCaches::getExtensionCacheVersion(); + // 확장 프론트엔드 병합 번들 URL (상시 ON — 활성 에셋이 없으면 null) + $bundleUrls = $this->buildExtensionBundleUrls($moduleAssets, $pluginAssets, $extensionCacheVersion); + $view->with('activeAdminTemplate', $activeTemplate); $view->with('extensionCacheVersion', $extensionCacheVersion); $view->with('frontendSettings', $frontendSettings); @@ -106,12 +109,39 @@ class TemplateComposer $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, + ]; + } + /** * 활성화된 모듈의 프론트엔드 에셋 정보를 수집합니다. * diff --git a/app/Http/View/Composers/UserTemplateComposer.php b/app/Http/View/Composers/UserTemplateComposer.php index c6460938..d145b0b1 100644 --- a/app/Http/View/Composers/UserTemplateComposer.php +++ b/app/Http/View/Composers/UserTemplateComposer.php @@ -105,6 +105,9 @@ class UserTemplateComposer // 확장 기능 캐시 버전 (브라우저 캐시 무효화용) $extensionCacheVersion = ClearsTemplateCaches::getExtensionCacheVersion(); + // 확장 프론트엔드 병합 번들 URL (상시 ON — 활성 에셋이 없으면 null) + $bundleUrls = $this->buildExtensionBundleUrls($moduleAssets, $pluginAssets, $extensionCacheVersion); + $view->with('activeUserTemplate', $activeTemplate); $view->with('extensionCacheVersion', $extensionCacheVersion); $view->with('frontendSettings', $frontendSettings); @@ -112,12 +115,39 @@ class UserTemplateComposer $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, + ]; + } + /** * 활성화된 모듈의 프론트엔드 에셋 정보를 수집합니다. * diff --git a/app/Services/ExtensionBundleService.php b/app/Services/ExtensionBundleService.php new file mode 100644 index 00000000..9966b4c5 --- /dev/null +++ b/app/Services/ExtensionBundleService.php @@ -0,0 +1,475 @@ +` 로 실행해도 등록 로직은 동일하게 동작한다. + * + * 정렬/필터(`hasAssets()` && strategy==='global' + `uasort(priority)`) 는 + * TemplateComposer 와 공유하는 SSoT 로 이 서비스에 둔다(drift 방지). + * + * 경로는 절대경로 게터(`getBuiltAssetAbsolutePaths()`)를 재사용한다 — + * `ModuleService::getAssetFilePath()` 의 `base_path("modules/{id}/...")` + * 하드코딩을 복제하지 않아야 `_bundled` 확장에서도 정확히 읽는다(제약 4). + * + * @see TemplateComposer + */ +class ExtensionBundleService +{ + // ext.cache_version 게터 재사용 — 트레이트를 use 해 self:: 로 호출(트레이트명 + // 직접 정적 호출은 PHP 8.3+ deprecated). 인스턴스 캐시 무효화 메서드는 미사용. + use ClearsTemplateCaches; + + /** + * 번들 캐시 파일이 저장되는 스토리지 디스크(= storage/app/ext-bundles). + */ + private const BUNDLE_DISK = 'ext-bundles'; + + /** + * 서비스 주입 + * + * @param ModuleManager $moduleManager 모듈 매니저 + * @param PluginManager $pluginManager 플러그인 매니저 + */ + public function __construct( + private readonly ModuleManager $moduleManager, + private readonly PluginManager $pluginManager + ) {} + + /** + * 확장 타입별 global 전략 에셋을 priority 오름차순으로 정렬해 반환합니다. + * + * TemplateComposer 의 개별 에셋 URL 생성과 번들러의 concat 이 동일한 + * 필터/정렬을 쓰도록 하는 SSoT. 순서 제어는 오직 manifest + * `loading.priority` 숫자 오름차순뿐이며 특정 확장 이름 하드코딩은 없다(제약 1). + * + * @param string $type 'module' | 'plugin' + * @return array + * identifier => 절대경로/우선순위 (priority 오름차순 정렬) + */ + public function getOrderedGlobalAssetPaths(string $type): array + { + $extensions = $type === 'plugin' + ? $this->pluginManager->getActivePlugins() + : $this->moduleManager->getActiveModules(); + + $ordered = []; + + foreach ($extensions as $extension) { + if (! $extension->hasAssets()) { + continue; + } + + $loadingConfig = $extension->getAssetLoadingConfig(); + + // global 전략만 번들 대상 (layout, lazy 는 레이아웃에서 개별 처리) + if (($loadingConfig['strategy'] ?? 'global') !== 'global') { + continue; + } + + $absolutePaths = $extension->getBuiltAssetAbsolutePaths(); + + $jsAbsPath = $absolutePaths['js'] ?? null; + $cssAbsPath = $absolutePaths['css'] ?? null; + + // JS/CSS 둘 다 없으면 번들에 기여할 것이 없으므로 제외 + if ($jsAbsPath === null && $cssAbsPath === null) { + continue; + } + + $ordered[$extension->getIdentifier()] = [ + 'jsAbsPath' => $jsAbsPath, + 'cssAbsPath' => $cssAbsPath, + 'priority' => (int) ($loadingConfig['priority'] ?? 100), + ]; + } + + // priority 오름차순 (낮을수록 먼저) — 개별 로딩과 동일 규칙 + uasort($ordered, fn ($a, $b) => $a['priority'] <=> $b['priority']); + + return $ordered; + } + + /** + * 확장 타입의 JS 번들 문자열을 생성합니다. + * + * priority 순으로 각 IIFE 파일을 읽어 `\n;\n` 구분자로 이어붙인다(제약 2 — + * ASI 경계 보호). 각 파일 끝의 `//# sourceMappingURL` 주석은 prod 에서는 + * strip(맵 생략), dev 에서는 개별 에셋 서빙 절대 URL 로 rewrite 한다(제약 3). + * + * 확장별 fine-grained try/catch — 파일 읽기 실패 시 해당 확장만 skip + + * Log::warning, 나머지 병합 지속(한 확장 실패가 번들 전체를 붕괴시키지 않음). + * + * @param string $type 'module' | 'plugin' + * @return string 병합된 JS (활성 global 에셋이 없으면 빈 문자열) + */ + public function buildJsBundle(string $type): string + { + $ordered = $this->getOrderedGlobalAssetPaths($type); + $isProduction = app()->environment('production'); + $segments = []; + + foreach ($ordered as $identifier => $paths) { + if (empty($paths['jsAbsPath'])) { + continue; + } + + try { + $content = @file_get_contents($paths['jsAbsPath']); + + if ($content === false) { + Log::warning('확장 JS 번들 병합: 파일 읽기 실패, 해당 확장 skip', [ + 'type' => $type, + 'identifier' => $identifier, + 'path' => $paths['jsAbsPath'], + ]); + + continue; + } + + $segments[] = $this->processJsSourceMap($content, $type, $identifier, $isProduction); + } catch (\Throwable $e) { + Log::warning('확장 JS 번들 병합 중 오류, 해당 확장 skip', [ + 'type' => $type, + 'identifier' => $identifier, + 'error' => $e->getMessage(), + ]); + } + } + + return implode("\n;\n", $segments); + } + + /** + * 확장 타입의 CSS 번들 문자열을 생성합니다. + * + * priority 순으로 각 CSS 파일을 읽어 `\n` 구분자로 이어붙인다. 상대경로 + * `url(...)` 참조가 있는 CSS 는 병합 시 경로가 깨지므로 번들에서 제외하고 + * 경고 로그를 남긴다(안전장치 — 현재 번들 CSS 는 url() 0건). + * + * @param string $type 'module' | 'plugin' + * @return string 병합된 CSS (활성 global 에셋이 없으면 빈 문자열) + */ + public function buildCssBundle(string $type): string + { + $ordered = $this->getOrderedGlobalAssetPaths($type); + $segments = []; + + foreach ($ordered as $identifier => $paths) { + if (empty($paths['cssAbsPath'])) { + continue; + } + + try { + $content = @file_get_contents($paths['cssAbsPath']); + + if ($content === false) { + Log::warning('확장 CSS 번들 병합: 파일 읽기 실패, 해당 확장 skip', [ + 'type' => $type, + 'identifier' => $identifier, + 'path' => $paths['cssAbsPath'], + ]); + + continue; + } + + // 상대경로 url() 참조가 있으면 병합 시 폰트/이미지 경로가 깨진다. + // 절대/data URI 는 안전하므로 상대경로만 검출해 해당 CSS 제외. + if ($this->hasRelativeUrl($content)) { + Log::warning('확장 CSS 에 상대경로 url() 존재 — 번들에서 제외(개별 폴백 유지)', [ + 'type' => $type, + 'identifier' => $identifier, + ]); + + continue; + } + + $segments[] = $content; + } catch (\Throwable $e) { + Log::warning('확장 CSS 번들 병합 중 오류, 해당 확장 skip', [ + 'type' => $type, + 'identifier' => $identifier, + 'error' => $e->getMessage(), + ]); + } + } + + return implode("\n", $segments); + } + + /** + * 캐시된 번들 파일의 절대 경로를 반환합니다(없으면 build → 원자적 write). + * + * 파일명에 version 을 포함(`{type}.{version}.{js|css}`)하므로 활성 조합이 + * 바뀌어 version 이 bump 되면 새 파일명으로 자연 무효화된다. 프로덕션에서만 + * 디스크 캐시하며, 비프로덕션(dev/watch)에서는 캐시하지 않고 매 요청 build 해 + * rebuild 를 즉시 반영한다(PO 결정). + * + * @param string $type 'module' | 'plugin' + * @param string $kind 'js' | 'css' + * @param int $version 확장 캐시 버전(ClearsTemplateCaches::getExtensionCacheVersion) + * @return string 캐시(또는 방금 build 한) 파일의 절대 경로. 병합 결과가 빈 문자열이면 빈 문자열. + */ + public function getBundleFilePath(string $type, string $kind, int $version): string + { + $content = $kind === 'css' + ? $this->buildCssBundle($type) + : $this->buildJsBundle($type); + + // 병합할 에셋이 하나도 없으면 파일을 만들지 않는다(호출측이 빈 문자열로 판단). + if ($content === '') { + return ''; + } + + $storage = $this->bundleStorage(); + $relativeName = $this->bundleFileName($type, $kind, $version); + + // 비프로덕션은 캐시하지 않고 임시 파일로 매번 build → rebuild 즉시 반영 + if (! app()->environment('production')) { + return $this->writeAtomically($storage, $relativeName, $content, cache: false); + } + + // 프로덕션: 동일 version 캐시가 있으면 그대로 사용 + if ($storage->exists('', $relativeName)) { + return $storage->getBasePath('').'/'.$relativeName; + } + + return $this->writeAtomically($storage, $relativeName, $content, cache: true); + } + + /** + * 현재 version 외의 오래된 번들 파일을 삭제하고 삭제 건수를 반환합니다. + * + * @param int $currentVersion 보존할 현재 캐시 버전 + * @return int 삭제된 파일 수 + */ + public function cleanupStaleBundles(int $currentVersion): int + { + $storage = $this->bundleStorage(); + $deleted = 0; + + foreach ($storage->files('', '') as $file) { + $name = basename($file); + + // 현재 version 파일과 .gitignore 는 보존 + if ($name === '.gitignore' || $this->matchesVersion($name, $currentVersion)) { + continue; + } + + if ($this->isBundleFile($name) && $storage->delete('', $name)) { + $deleted++; + } + } + + return $deleted; + } + + /** + * 번들 캐시 파일을 삭제합니다(cache-clear 커맨드용). + * + * @param string|null $type 'module' | 'plugin' 지정 시 해당 타입만, null 이면 전체 + * @return int 삭제된 파일 수 + */ + public function clearBundles(?string $type = null): int + { + $storage = $this->bundleStorage(); + $deleted = 0; + + foreach ($storage->files('', '') as $file) { + $name = basename($file); + + if ($name === '.gitignore' || ! $this->isBundleFile($name)) { + continue; + } + + // 타입 필터 (파일명 접두사 `{type}.`) + if ($type !== null && ! str_starts_with($name, $type.'.')) { + continue; + } + + if ($storage->delete('', $name)) { + $deleted++; + } + } + + return $deleted; + } + + /** + * 번들 파일명을 생성합니다(`{type}.{version}.{kind}`). + * + * @param string $type 'module' | 'plugin' + * @param string $kind 'js' | 'css' + * @param int $version 캐시 버전 + * @return string 파일명 (디렉토리 제외) + */ + private function bundleFileName(string $type, string $kind, int $version): string + { + return "{$type}.{$version}.{$kind}"; + } + + /** + * 파일명이 번들 파일 패턴(`{type}.{version}.{kind}`)인지 확인합니다. + * + * @param string $name 파일명 + * @return bool 번들 파일이면 true + */ + private function isBundleFile(string $name): bool + { + return (bool) preg_match('/^(module|plugin)\.\d+\.(js|css)$/', $name); + } + + /** + * 파일명이 지정한 version 의 번들인지 확인합니다. + * + * @param string $name 파일명 + * @param int $version 비교할 버전 + * @return bool 해당 version 파일이면 true + */ + private function matchesVersion(string $name, int $version): bool + { + return (bool) preg_match('/^(module|plugin)\.'.preg_quote((string) $version, '/').'\.(js|css)$/', $name); + } + + /** + * 병합 결과를 원자적으로(임시 파일 → rename) 기록하고 절대 경로를 반환합니다. + * + * @param CoreStorageDriver $storage 번들 디스크 스토리지 + * @param string $relativeName 대상 파일명 + * @param string $content 기록할 내용 + * @param bool $cache true 면 version 파일명 유지, false 면 임시 파일 사용 + * @return string 기록된 파일의 절대 경로 + */ + private function writeAtomically(CoreStorageDriver $storage, string $relativeName, string $content, bool $cache): string + { + $basePath = $storage->getBasePath(''); + + if (! is_dir($basePath)) { + @mkdir($basePath, 0o755, true); + } + + $finalPath = $basePath.'/'.$relativeName; + + if (! $cache) { + // 비프로덕션: 매 요청 덮어써도 무방(원자성 불요), 그대로 write + $storage->put('', $relativeName, $content); + + return $finalPath; + } + + // 프로덕션: 임시 파일에 쓴 뒤 rename 으로 원자적 게시(부분 파일 서빙 방지) + $tmpName = $relativeName.'.tmp.'.getmypid(); + $storage->put('', $tmpName, $content); + + $tmpPath = $basePath.'/'.$tmpName; + + if (! @rename($tmpPath, $finalPath)) { + // rename 실패 시(경합으로 이미 존재 등) 임시 파일 정리 후 최종 경로 사용 + $storage->delete('', $tmpName); + } + + return $finalPath; + } + + /** + * IIFE 소스맵 주석을 환경에 맞게 처리합니다. + * + * prod: `//# sourceMappingURL` 라인 strip(맵 생략). + * dev: 개별 에셋 서빙 절대 URL(`/api/{type}s/assets/{id}/dist/js/*.map`)로 + * rewrite → 브라우저가 확장별 원본 맵을 추적(완벽한 통합 맵은 아님). + * + * @param string $content 원본 IIFE 내용 + * @param string $type 'module' | 'plugin' + * @param string $identifier 확장 식별자 + * @param bool $isProduction 프로덕션 여부 + * @return string 처리된 내용 + */ + private function processJsSourceMap(string $content, string $type, string $identifier, bool $isProduction): string + { + // 구분자로 `~` 사용 — 패턴 자체에 `#`(`//#`)가 포함되어 `#` 구분자는 못 씀 + $pattern = '~//# sourceMappingURL=(\S+)~'; + + if ($isProduction) { + // prod: 맵 참조 제거 + return preg_replace($pattern, '', $content) ?? $content; + } + + // dev: 상대 맵 파일명을 개별 에셋 서빙 절대 URL 로 rewrite + $typeSegment = $type === 'plugin' ? 'plugins' : 'modules'; + + return preg_replace_callback($pattern, function (array $m) use ($typeSegment, $identifier) { + $mapFile = ltrim($m[1], './'); + + return '//# sourceMappingURL=/api/'.$typeSegment.'/assets/'.$identifier.'/dist/js/'.basename($mapFile); + }, $content) ?? $content; + } + + /** + * CSS 내용에 상대경로 url() 참조가 있는지 확인합니다. + * + * 절대 URL(http/https), 루트 절대경로(/), data URI 는 병합에 안전하므로 + * 그 외의 url() 참조만 상대경로로 간주한다. + * + * @param string $css CSS 내용 + * @return bool 상대경로 url() 이 하나라도 있으면 true + */ + private function hasRelativeUrl(string $css): bool + { + if (! preg_match_all('/url\(\s*[\'"]?([^\'")]+)[\'"]?\s*\)/i', $css, $matches)) { + return false; + } + + foreach ($matches[1] as $url) { + $url = trim($url); + + if ($url === '') { + continue; + } + + $isAbsolute = str_starts_with($url, 'http://') + || str_starts_with($url, 'https://') + || str_starts_with($url, '//') + || str_starts_with($url, '/') + || str_starts_with($url, 'data:'); + + if (! $isAbsolute) { + return true; + } + } + + return false; + } + + /** + * 번들 디스크용 스토리지 드라이버를 반환합니다(StorageInterface 경유). + * + * @return CoreStorageDriver ext-bundles 디스크 스토리지 + */ + private function bundleStorage(): CoreStorageDriver + { + return (new CoreStorageDriver)->withDisk(self::BUNDLE_DISK); + } + + /** + * 현재 확장 캐시 버전을 반환합니다. + * + * @return int 캐시 버전 + */ + public function getCurrentVersion(): int + { + return self::getExtensionCacheVersion(); + } +} diff --git a/config/filesystems.php b/config/filesystems.php index ede7cb8e..56165197 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -74,6 +74,16 @@ return [ 'report' => false, ], + // 확장(모듈/플러그인) 프론트엔드 IIFE/CSS 번들 병합 결과 캐시. + // ExtensionBundleService 가 version-in-path 파일명으로 저장/서빙한다. + 'ext-bundles' => [ + 'driver' => 'local', + 'root' => storage_path('app/ext-bundles'), + 'serve' => false, + 'throw' => true, + 'report' => false, + ], + 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index 08d14bb6..621254f3 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -27,6 +27,8 @@ _bundled에서 TSX/TS 파일 수정한 경우 → 빌드 후 확장 업데이 | `modules/**/resources/js/**/*.ts` (핸들러) | `module:build` + `module:update {id} --force` | | `templates/**/src/**/*.tsx` (컴포넌트) | `template:build` + `template:update {id} --force` | +> **확장 프론트엔드 번들**: 활성 모듈/플러그인 IIFE 는 서버측에서 종류별 1개 번들로 병합 서빙된다(`/api/{modules,plugins}/bundle.{js,css}`). `{type}:update` 후 확장 캐시 버전이 bump 되면 번들이 자동 재생성된다(prod 캐시, dev 매 요청 concat). 구버전 번들 파일은 `ext-bundles:cleanup` 또는 `{type}:cache-clear` 로 정리. 상세: [module-assets.md](extension/module-assets.md#서버측-번들-병합). + --- ## 확장 업데이트 (_bundled → 활성 반영) diff --git a/docs/extension/module-assets.md b/docs/extension/module-assets.md index 0bc197e1..2614a7bb 100644 --- a/docs/extension/module-assets.md +++ b/docs/extension/module-assets.md @@ -381,6 +381,62 @@ GET /api/modules/assets/{identifier}/{path} --- +## 서버측 번들 병합 (Server-side Bundle) + +활성 모듈/플러그인이 늘어날수록 개별 IIFE JS/CSS 요청이 선형 증가한다. 이를 줄이기 위해 코어는 타입별(모듈/플러그인)로 활성 `global` 에셋을 서버에서 하나의 번들로 병합해 서빙한다. 각 확장 IIFE 는 자체 클로저에서 자가등록(레지스트리 + 핸들러/리스너)을 수행하므로, priority 순으로 이어붙여 단일 `