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/*)
## 버전 동기화 판정 ( 결정)
코어 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 판정을 강제(이번 초기 누락 재발 방지)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
## [7.0.1] - 2026-07-03
|
||||
|
||||
### Added
|
||||
|
||||
- 설치된 모듈·플러그인이 늘어날수록 화면마다 개별로 내려받던 확장 스크립트·스타일 요청을 종류별로 하나로 묶어 내려받도록 개선해, 확장이 많은 사이트의 초기 로딩 요청 수를 크게 줄였습니다. 확장을 설치·활성화·비활성화하면 묶음이 자동으로 다시 만들어져 항상 현재 활성 상태와 일치합니다.
|
||||
|
||||
### Changed
|
||||
|
||||
- 첫 화면 접속 시 내려받는 코어 스크립트 용량을 절반 이하로 줄여 초기 로딩을 개선했습니다. 레이아웃 편집기와 개발자 진단도구를 코어에서 분리해, 편집기는 편집 화면에 들어갈 때만, 진단도구는 디버그 모드를 켰을 때만 내려받도록 했습니다. 일반 사용자는 평소 쓰지 않는 편집기·진단도구 코드를 더 이상 받지 않습니다.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ExtensionBundleService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 오래된 확장 프론트엔드 병합 번들 파일을 정리하는 커맨드
|
||||
*
|
||||
* 번들 캐시 파일(`storage/app/ext-bundles/{type}.{version}.{js|css}`)은 Laravel
|
||||
* 캐시 스토어 밖 파일시스템이라 version bump 나 cache:clear 가 구버전 파일을
|
||||
* 지우지 않는다(TTL/GC 없음). 이 커맨드가 현재 version 외의 구파일을 삭제한다.
|
||||
* (`CleanupLayoutPreviewsCommand` 파일 산출물 GC 패턴 미러)
|
||||
*/
|
||||
class CleanupExtensionBundlesCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'ext-bundles:cleanup';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '오래된 확장 프론트엔드 병합 번들 파일(구 version)을 삭제합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @param ExtensionBundleService $service 번들 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(ExtensionBundleService $service): int
|
||||
{
|
||||
$currentVersion = $service->getCurrentVersion();
|
||||
$deleted = $service->cleanupStaleBundles($currentVersion);
|
||||
|
||||
$this->info("오래된 확장 번들 {$deleted}건이 삭제되었습니다. (현재 version: {$currentVersion})");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
]));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 스토리지 드라이버 인스턴스 반환
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인이 설정 페이지를 가지고 있는지 확인합니다.
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 에셋 서빙
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 에셋 서빙
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성화된 모듈의 프론트엔드 에셋 정보를 수집합니다.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성화된 모듈의 프론트엔드 에셋 정보를 수집합니다.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Extension\Storage\CoreStorageDriver;
|
||||
use App\Extension\Traits\ClearsTemplateCaches;
|
||||
use App\Http\View\Composers\TemplateComposer;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 확장(모듈/플러그인) 프론트엔드 IIFE/CSS 번들 병합 서비스
|
||||
*
|
||||
* 활성 모듈/플러그인의 개별 IIFE JS·CSS 에셋을 타입별로 하나의 번들 파일로
|
||||
* 이어붙여(concat) 서빙 오버헤드(HTTP 요청 수)를 줄인다. 각 확장 IIFE 는
|
||||
* 자체 클로저에서 자가등록(`window.G7ModuleRegistry`/`G7PluginRegistry` +
|
||||
* 핸들러/리스너)을 수행하므로, N개 IIFE 를 priority 순으로 이어붙여 1개
|
||||
* `<script>` 로 실행해도 등록 로직은 동일하게 동작한다.
|
||||
*
|
||||
* 정렬/필터(`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<string, array{jsAbsPath: ?string, cssAbsPath: ?string, priority: int}>
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 → 활성 반영)
|
||||
|
||||
@@ -381,6 +381,62 @@ GET /api/modules/assets/{identifier}/{path}
|
||||
|
||||
---
|
||||
|
||||
## 서버측 번들 병합 (Server-side Bundle)
|
||||
|
||||
활성 모듈/플러그인이 늘어날수록 개별 IIFE JS/CSS 요청이 선형 증가한다. 이를 줄이기 위해 코어는 타입별(모듈/플러그인)로 활성 `global` 에셋을 서버에서 하나의 번들로 병합해 서빙한다. 각 확장 IIFE 는 자체 클로저에서 자가등록(레지스트리 + 핸들러/리스너)을 수행하므로, priority 순으로 이어붙여 단일 `<script>` 로 실행해도 등록 동작은 동일하다.
|
||||
|
||||
### 서빙 엔드포인트
|
||||
|
||||
```
|
||||
GET /api/modules/bundle.js?v={version}
|
||||
GET /api/modules/bundle.css?v={version}
|
||||
GET /api/plugins/bundle.js?v={version}
|
||||
GET /api/plugins/bundle.css?v={version}
|
||||
```
|
||||
|
||||
`{version}` = 확장 캐시 버전(`ClearsTemplateCaches::getExtensionCacheVersion()`). 활성 조합이 바뀌면 install/activate/deactivate/update 라이프사이클에서 version 이 bump 되어 새 URL → 새 캐시 파일명으로 자동 무효화된다.
|
||||
|
||||
### 동작 흐름
|
||||
|
||||
```
|
||||
1. blade → window.G7Config.bundleUrls 주입 (활성 에셋 없는 타입은 null)
|
||||
2. TemplateApp.loadExtensionAssets()
|
||||
└─ ModuleAssetLoader.loadBundle('module', ...) → loadBundle('plugin', ...)
|
||||
├─ 단일 <script async=false> + 단일 <link> append
|
||||
└─ 번들 내부 물리 순서(=priority 정렬)로 IIFE 자가등록 실행
|
||||
```
|
||||
|
||||
`bundleUrls` 가 없으면(구버전 blade) `ModuleAssetLoader.loadActiveExtensionAssets` 개별 로딩으로 폴백한다.
|
||||
|
||||
### 병합 규율
|
||||
|
||||
| 규율 | 내용 | audit 룰 |
|
||||
|------|------|---------|
|
||||
| priority 순서 | manifest `loading.priority` 오름차순만. 확장 이름 하드코딩 금지 | (선언형) |
|
||||
| `\n;\n` 구분자 | IIFE 사이는 `\n;\n`(JS)/`\n`(CSS). 미사용 시 ASI 붕괴 → 전체 파싱 에러 | `extension-bundle-concat-separator` |
|
||||
| 소스맵 | prod strip, dev 는 개별 에셋 서빙 절대 URL 로 rewrite | - |
|
||||
| same-origin | 번들 URL 은 `/api/...` 만 (CDN 금지 — gdpr preblocker 자기차단 방지) | `extension-bundle-url-same-origin` |
|
||||
| 절대경로 게터 | `getBuiltAssetAbsolutePaths()` 사용. `base_path("modules"\|"plugins")` 직접 조립 금지 | `extension-bundle-asset-path-getter` |
|
||||
| 확장별 try/catch | 파일 읽기 실패 시 해당 확장만 skip, 나머지 병합 지속 | (메모리+회귀테스트) |
|
||||
| CSS url() | 상대경로 url() 을 가진 CSS 는 번들 제외(개별 폴백) | - |
|
||||
|
||||
### 캐시 stale 관리
|
||||
|
||||
번들 파일(`storage/app/ext-bundles/{type}.{version}.{js,css}`)은 Laravel 캐시 스토어 밖 파일시스템이라 version bump/`cache:clear` 가 구파일을 지우지 않는다. 정리 경로:
|
||||
|
||||
```bash
|
||||
php artisan ext-bundles:cleanup # 현재 version 외 구파일 삭제
|
||||
php artisan module:cache-clear # 모듈 번들 파일 정리 포함
|
||||
php artisan plugin:cache-clear # 플러그인 번들 파일 정리 포함
|
||||
php artisan template:cache-clear # 전체 번들 파일 정리 포함
|
||||
```
|
||||
|
||||
프로덕션은 version-in-path 디스크 캐시, 비프로덕션(dev/watch)은 캐시 없이 매 요청 concat(rebuild 즉시 반영). `_bundled` 수정 후에는 `{type}:update {id} --force` 로 활성 반영 후 version bump 로 번들이 재생성된다.
|
||||
|
||||
> 개별 에셋 서빙 라우트(`/api/{type}/assets/...`, `*.map` 포함)는 소스맵·static 참조를 위해 존치한다.
|
||||
|
||||
---
|
||||
|
||||
## 에셋 로딩 전략
|
||||
|
||||
### global (기본값)
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+22
-22
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -26,7 +26,7 @@ import { getErrorHandlingResolver } from './error';
|
||||
import type { ErrorHandlingMap } from './types/ErrorHandling';
|
||||
import { createLogger, Logger } from './utils/Logger';
|
||||
import { webSocketManager } from './websocket/WebSocketManager';
|
||||
import { getModuleAssetLoader, parseModuleAssetsFromConfig, parsePluginAssetsFromConfig } from './modules';
|
||||
import { getModuleAssetLoader, parseModuleAssetsFromConfig, parsePluginAssetsFromConfig, parseBundleUrlsFromConfig } from './modules';
|
||||
import { SystemBannerManager } from './template-engine/SystemBannerManager';
|
||||
/**
|
||||
* DevTools 추적 - G7DevToolsCore.getInstance() 직접 호출 대신 G7Core.devTools를 사용합니다.
|
||||
@@ -722,41 +722,64 @@ export class TemplateApp {
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈/플러그인 에셋 로드
|
||||
* 모듈/플러그인 에셋 로드 (서버측 병합 번들)
|
||||
*
|
||||
* window.G7Config.moduleAssets 및 pluginAssets에서 에셋 정보를 읽어
|
||||
* 동적으로 JS/CSS를 로드합니다.
|
||||
* window.G7Config.bundleUrls 에서 병합 번들 URL 을 읽어 모듈 번들 →
|
||||
* 플러그인 번들 순으로 로드한다. 각 번들은 활성 확장 IIFE 를 priority 순으로
|
||||
* 이어붙인 단일 파일이며, 로드 즉시 각 IIFE 가 자가등록(핸들러/리스너)을
|
||||
* 실행한다. 개별 로딩(loadActiveExtensionAssets)과 실행 계약은 동일하다.
|
||||
*
|
||||
* 모듈 JS는 IIFE 형태로 빌드되어 로드 즉시 initModule()이 실행되고,
|
||||
* ActionDispatcher에 핸들러가 등록됩니다.
|
||||
* 모듈 → 플러그인 순서를 유지하는 이유: gdpr preblocker 등 인터셉터가
|
||||
* 플러그인 번들 내 priority 최상단으로 오되 모듈보다는 뒤에 실행된다
|
||||
* (2번들 구조). bundleUrls 가 없으면(구버전 blade) 개별 로딩으로 폴백한다.
|
||||
*
|
||||
* @since engine-v1.52.0 (서버측 번들 로딩으로 전환)
|
||||
*/
|
||||
private async loadExtensionAssets(): Promise<void> {
|
||||
try {
|
||||
const moduleAssetLoader = getModuleAssetLoader();
|
||||
const bundleUrls = parseBundleUrlsFromConfig();
|
||||
|
||||
// 모듈 에셋 로드
|
||||
const moduleAssets = parseModuleAssetsFromConfig();
|
||||
if (moduleAssets.length > 0) {
|
||||
logger.log('Loading module assets:', moduleAssets.map(m => m.identifier));
|
||||
await moduleAssetLoader.loadActiveExtensionAssets(moduleAssets);
|
||||
// bundleUrls 부재 시 개별 로딩 폴백 (회귀 안전)
|
||||
if (!bundleUrls) {
|
||||
await this.loadExtensionAssetsIndividually();
|
||||
return;
|
||||
}
|
||||
|
||||
// 플러그인 에셋 로드
|
||||
const pluginAssets = parsePluginAssetsFromConfig();
|
||||
if (pluginAssets.length > 0) {
|
||||
logger.log('Loading plugin assets:', pluginAssets.map(p => p.identifier));
|
||||
await moduleAssetLoader.loadActiveExtensionAssets(pluginAssets);
|
||||
}
|
||||
// 모듈 번들 → 플러그인 번들 순서 (gdpr 는 플러그인 번들 내 최상단)
|
||||
await moduleAssetLoader.loadBundle('module', bundleUrls.moduleJs, bundleUrls.moduleCss);
|
||||
await moduleAssetLoader.loadBundle('plugin', bundleUrls.pluginJs, bundleUrls.pluginCss);
|
||||
|
||||
if (moduleAssets.length > 0 || pluginAssets.length > 0) {
|
||||
logger.log('Extension assets loaded successfully');
|
||||
}
|
||||
logger.log('Extension bundle assets loaded successfully');
|
||||
} catch (error) {
|
||||
// 에셋 로드 실패는 경고만 출력하고 앱 계속 진행
|
||||
logger.warn('Failed to load extension assets:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 확장 에셋 로드 (bundleUrls 부재 시 폴백)
|
||||
*
|
||||
* window.G7Config.moduleAssets/pluginAssets 에서 확장별 개별 URL 을 읽어
|
||||
* priority 순으로 각각 로드한다. 서버측 번들이 준비되지 않은 구버전 blade
|
||||
* 환경 회귀 안전용.
|
||||
*/
|
||||
private async loadExtensionAssetsIndividually(): Promise<void> {
|
||||
const moduleAssetLoader = getModuleAssetLoader();
|
||||
|
||||
const moduleAssets = parseModuleAssetsFromConfig();
|
||||
if (moduleAssets.length > 0) {
|
||||
logger.log('Loading module assets (individual fallback):', moduleAssets.map(m => m.identifier));
|
||||
await moduleAssetLoader.loadActiveExtensionAssets(moduleAssets);
|
||||
}
|
||||
|
||||
const pluginAssets = parsePluginAssetsFromConfig();
|
||||
if (pluginAssets.length > 0) {
|
||||
logger.log('Loading plugin assets (individual fallback):', pluginAssets.map(p => p.identifier));
|
||||
await moduleAssetLoader.loadActiveExtensionAssets(pluginAssets);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 라우트 변경 핸들러
|
||||
*/
|
||||
|
||||
@@ -96,6 +96,121 @@ export class ModuleAssetLoader {
|
||||
logger.log('All module assets loaded successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버측에서 병합된 확장 번들(JS/CSS)을 로드합니다.
|
||||
*
|
||||
* 활성 모듈/플러그인 IIFE 를 priority 순으로 이어붙인 단일 파일을 하나의
|
||||
* `<script async=false>` 로, CSS 는 하나의 `<link>` 로 append 한다. 개별
|
||||
* 로딩과 달리 확장 수와 무관하게 요청 1(+1)건으로 끝난다. `<script async=false>`
|
||||
* 는 번들 내부 물리 순서로 실행되므로 병합 시 priority 정렬이 곧 실행 순서다.
|
||||
*
|
||||
* 중복 append 가드 — 같은 key(module/plugin) 는 최초 1회만 로드한다.
|
||||
*
|
||||
* @param key 번들 구분 키 (예: 'module' | 'plugin')
|
||||
* @param jsUrl 병합 JS URL (없으면 스킵)
|
||||
* @param cssUrl 병합 CSS URL (없으면 스킵)
|
||||
* @since engine-v1.52.0
|
||||
*/
|
||||
async loadBundle(key: string, jsUrl?: string | null, cssUrl?: string | null): Promise<void> {
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
if (cssUrl) {
|
||||
promises.push(this.loadBundleCss(key, cssUrl));
|
||||
}
|
||||
|
||||
if (jsUrl) {
|
||||
promises.push(this.loadBundleJs(key, jsUrl));
|
||||
}
|
||||
|
||||
if (promises.length === 0) {
|
||||
logger.log(`No bundle assets to load for: ${key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* 병합 CSS 번들을 단일 `<link>` 로 로드합니다(중복 가드).
|
||||
*
|
||||
* @param key 번들 구분 키
|
||||
* @param url 병합 CSS URL
|
||||
*/
|
||||
private async loadBundleCss(key: string, url: string): Promise<void> {
|
||||
const elementId = `ext-bundle-css-${key}`;
|
||||
|
||||
if (document.getElementById(elementId)) {
|
||||
logger.log(`Bundle CSS already loaded: ${key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = url;
|
||||
link.id = elementId;
|
||||
|
||||
link.onload = () => {
|
||||
logger.log(`Bundle CSS loaded: ${key}`);
|
||||
this.registerLoadedAsset(`bundle-${key}`, { type: 'css', element: link });
|
||||
resolve();
|
||||
};
|
||||
|
||||
link.onerror = () => {
|
||||
logger.warn(`Failed to load bundle CSS: ${key} (${url})`);
|
||||
resolve();
|
||||
};
|
||||
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 병합 JS 번들을 단일 `<script async=false>` 로 로드합니다(중복 가드).
|
||||
*
|
||||
* @param key 번들 구분 키
|
||||
* @param url 병합 JS URL
|
||||
*/
|
||||
private async loadBundleJs(key: string, url: string): Promise<void> {
|
||||
const elementId = `ext-bundle-js-${key}`;
|
||||
|
||||
if (document.getElementById(elementId)) {
|
||||
logger.log(`Bundle JS already loaded: ${key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingPromise = this.loadingPromises.get(elementId);
|
||||
if (existingPromise) {
|
||||
logger.log(`Bundle JS already loading: ${key}`);
|
||||
return existingPromise;
|
||||
}
|
||||
|
||||
const loadPromise = new Promise<void>((resolve) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = url;
|
||||
script.id = elementId;
|
||||
script.async = false; // 번들 내부 물리 순서로 실행 보장
|
||||
|
||||
script.onload = () => {
|
||||
logger.log(`Bundle JS loaded: ${key}`);
|
||||
this.registerLoadedAsset(`bundle-${key}`, { type: 'js', element: script });
|
||||
this.loadingPromises.delete(elementId);
|
||||
resolve();
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
logger.warn(`Failed to load bundle JS: ${key} (${url})`);
|
||||
this.loadingPromises.delete(elementId);
|
||||
resolve();
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
this.loadingPromises.set(elementId, loadPromise);
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS 파일을 동적으로 로드합니다.
|
||||
*
|
||||
@@ -306,6 +421,42 @@ export function parseModuleAssetsFromConfig(): ModuleAsset[] {
|
||||
return moduleAssets;
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 병합 번들 URL 정보 인터페이스
|
||||
*/
|
||||
export interface ExtensionBundleUrls {
|
||||
/** 모듈 병합 JS 번들 URL */
|
||||
moduleJs?: string | null;
|
||||
/** 모듈 병합 CSS 번들 URL */
|
||||
moduleCss?: string | null;
|
||||
/** 플러그인 병합 JS 번들 URL */
|
||||
pluginJs?: string | null;
|
||||
/** 플러그인 병합 CSS 번들 URL */
|
||||
pluginCss?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* window.G7Config.bundleUrls 에서 병합 번들 URL 을 파싱합니다.
|
||||
*
|
||||
* 활성 global 에셋이 없는 타입은 서버가 null 을 내려주므로, 프론트는 해당
|
||||
* 번들 로드를 스킵한다. bundleUrls 자체가 없으면(구버전 blade 등) null 반환.
|
||||
*
|
||||
* @returns 번들 URL 객체 또는 null
|
||||
* @since engine-v1.52.0
|
||||
*/
|
||||
export function parseBundleUrlsFromConfig(): ExtensionBundleUrls | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const g7Config = (window as any).G7Config;
|
||||
if (!g7Config?.bundleUrls) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return g7Config.bundleUrls as ExtensionBundleUrls;
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 에셋을 파싱합니다.
|
||||
*
|
||||
|
||||
@@ -18,7 +18,7 @@ describe('ModuleAssetLoader', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.head.querySelectorAll('[id^="module-js-"],[id^="module-css-"]')
|
||||
document.head.querySelectorAll('[id^="module-js-"],[id^="module-css-"],[id^="ext-bundle-js-"],[id^="ext-bundle-css-"]')
|
||||
.forEach(el => el.remove());
|
||||
});
|
||||
|
||||
@@ -165,4 +165,110 @@ describe('ModuleAssetLoader', () => {
|
||||
await expect(loader.loadActiveExtensionAssets([])).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadBundle (서버측 병합 번들)', () => {
|
||||
it('단일 script + 단일 link 를 1개씩만 append 한다', async () => {
|
||||
const scriptAppends: string[] = [];
|
||||
const linkAppends: string[] = [];
|
||||
|
||||
const appendSpy = vi.spyOn(document.head, 'appendChild').mockImplementation(((node: any) => {
|
||||
if (node.tagName === 'SCRIPT') {
|
||||
scriptAppends.push(node.id);
|
||||
queueMicrotask(() => node.onload?.());
|
||||
} else if (node.tagName === 'LINK') {
|
||||
linkAppends.push(node.id);
|
||||
queueMicrotask(() => node.onload?.());
|
||||
}
|
||||
return node;
|
||||
}) as any);
|
||||
|
||||
await loader.loadBundle('module', '/api/modules/bundle.js?v=1', '/api/modules/bundle.css?v=1');
|
||||
|
||||
expect(scriptAppends).toEqual(['ext-bundle-js-module']);
|
||||
expect(linkAppends).toEqual(['ext-bundle-css-module']);
|
||||
|
||||
appendSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('번들 script 는 async=false 여야 한다 (내부 순서 실행 보장)', async () => {
|
||||
const created: HTMLScriptElement[] = [];
|
||||
const appendSpy = vi.spyOn(document.head, 'appendChild').mockImplementation(((node: any) => {
|
||||
if (node.tagName === 'SCRIPT') {
|
||||
created.push(node);
|
||||
queueMicrotask(() => node.onload?.());
|
||||
}
|
||||
return node;
|
||||
}) as any);
|
||||
|
||||
await loader.loadBundle('plugin', '/api/plugins/bundle.js?v=1', null);
|
||||
|
||||
expect(created).toHaveLength(1);
|
||||
expect(created[0].async).toBe(false);
|
||||
appendSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('같은 key 를 중복 로드하지 않는다 (중복 가드)', async () => {
|
||||
let scriptCount = 0;
|
||||
// 실제 DOM 에 삽입해 element id 가 등록되어야 getElementById 중복 가드가 동작
|
||||
const appendSpy = vi.spyOn(document.head, 'appendChild').mockImplementation(((node: any) => {
|
||||
if (node.tagName === 'SCRIPT') {
|
||||
scriptCount++;
|
||||
queueMicrotask(() => node.onload?.());
|
||||
}
|
||||
// jsdom 실제 삽입 (id 등록 → 두 번째 호출 시 getElementById 가 찾아 중복 방지)
|
||||
Node.prototype.appendChild.call(document.head, node);
|
||||
return node;
|
||||
}) as any);
|
||||
|
||||
await loader.loadBundle('module', '/api/modules/bundle.js?v=1', null);
|
||||
await loader.loadBundle('module', '/api/modules/bundle.js?v=1', null);
|
||||
|
||||
expect(scriptCount).toBe(1);
|
||||
appendSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('jsUrl/cssUrl 모두 null 이면 no-op', async () => {
|
||||
const appendSpy = vi.spyOn(document.head, 'appendChild');
|
||||
await expect(loader.loadBundle('module', null, null)).resolves.toBeUndefined();
|
||||
expect(appendSpy).not.toHaveBeenCalled();
|
||||
appendSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('concat 자가등록 (IIFE 병합 계약)', () => {
|
||||
it(';\\n 로 이어붙인 2개 IIFE 가 모두 실행되어 레지스트리에 자가등록된다', () => {
|
||||
// 모의 IIFE 2개 — ecommerce IIFE 처럼 세미콜론 없이 끝나도 `\n;\n` 구분자로 ASI 경계 보호
|
||||
const registry: Record<string, unknown> = {};
|
||||
(globalThis as any).__G7TestRegistry = registry;
|
||||
|
||||
const iifeA = `(function(){"use strict";globalThis.__G7TestRegistry["mod-a"]={handler:"mod-a.doThing"}})()`;
|
||||
// 세미콜론 없이 끝나는 IIFE (sourceMappingURL 주석 strip 후 형태)
|
||||
const iifeB = `(function(){"use strict";globalThis.__G7TestRegistry["mod-b"]={handler:"mod-b.doThing"}})()`;
|
||||
|
||||
const bundle = [iifeA, iifeB].join('\n;\n');
|
||||
|
||||
// 번들 실행 (단일 <script> 실행 시뮬레이션)
|
||||
// eslint-disable-next-line no-new-func
|
||||
new Function(bundle)();
|
||||
|
||||
expect(registry['mod-a']).toEqual({ handler: 'mod-a.doThing' });
|
||||
expect(registry['mod-b']).toEqual({ handler: 'mod-b.doThing' });
|
||||
// 핸들러 네임스페이스 격리 — 각 확장 prefix
|
||||
expect((registry['mod-a'] as any).handler).toContain('mod-a.');
|
||||
expect((registry['mod-b'] as any).handler).toContain('mod-b.');
|
||||
|
||||
delete (globalThis as any).__G7TestRegistry;
|
||||
});
|
||||
|
||||
it('구분자 없이 이어붙이면 ASI 경계가 깨진다 (구분자 필요성 회귀 가드)', () => {
|
||||
// 세미콜론 없이 끝나는 IIFE 뒤에 즉시 다음 IIFE 를 붙이면 파싱 에러
|
||||
const iifeA = `(function(){return 1})()`;
|
||||
const iifeB = `(function(){return 2})()`;
|
||||
|
||||
// 구분자 없는 병합 → `()()(function...` 형태로 호출 연쇄 파싱 (런타임 에러)
|
||||
expect(() => new Function(iifeA + '\n' + iifeB)()).toThrow();
|
||||
// 구분자 있는 병합 → 정상
|
||||
expect(() => new Function(iifeA + '\n;\n' + iifeB)()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ export {
|
||||
getModuleAssetLoader,
|
||||
parseModuleAssetsFromConfig,
|
||||
parsePluginAssetsFromConfig,
|
||||
parseBundleUrlsFromConfig,
|
||||
} from './ModuleAssetLoader';
|
||||
|
||||
export type { ModuleAsset, ExternalScript } from './ModuleAssetLoader';
|
||||
export type { ModuleAsset, ExternalScript, ExtensionBundleUrls } from './ModuleAssetLoader';
|
||||
|
||||
@@ -5,6 +5,19 @@
|
||||
>
|
||||
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
|
||||
|
||||
## [engine-v1.52.0] - 2026-07-04
|
||||
|
||||
### Added
|
||||
|
||||
#### 확장 프론트엔드 에셋 서버측 번들 로딩
|
||||
|
||||
- `TemplateApp.loadExtensionAssets()` — 활성 모듈/플러그인 에셋을 확장별 개별 로딩에서 **서버측 병합 번들 로딩**으로 전환한다. `window.G7Config.bundleUrls`(blade 주입)를 읽어 `ModuleAssetLoader.loadBundle('module', ...)` → `loadBundle('plugin', ...)` 순으로 종류별 1개 번들만 로드한다. 확장 IIFE 자가등록 계약(핸들러/리스너/preblocker)은 priority 순 물리 병합으로 실행 순서가 보존되어 그대로 동작한다. `bundleUrls` 부재 시(구버전 blade) 기존 개별 로딩(`loadExtensionAssetsIndividually`)으로 폴백한다.
|
||||
- `ModuleAssetLoader.loadBundle(key, jsUrl?, cssUrl?)`(신규) — 병합 번들을 단일 `<script async=false>` + 단일 `<link>` 로 append 하고 key(module/plugin)별 중복 로드를 가드한다. `<script async=false>` 로 번들 내부 물리 순서(=priority 정렬)가 곧 실행 순서다.
|
||||
- `ModuleAssetLoader.parseBundleUrlsFromConfig()` + `ExtensionBundleUrls` 타입(신규) — `G7Config.bundleUrls` 파서. 활성 global 에셋이 없는 타입은 서버가 null 을 내려주어 프론트가 로드를 스킵한다.
|
||||
- 기존 `loadActiveExtensionAssets`/`parse{Module,Plugin}AssetsFromConfig` 는 폴백 경로로 유지(dead-but-tested).
|
||||
|
||||
> 코어 측 번들 병합 서비스(`ExtensionBundleService`)·서빙 엔드포인트·캐시 정책은 루트 CHANGELOG [7.0.1] 및 `docs/extension/module-assets.md` "서버측 번들 병합" 참조.
|
||||
|
||||
## [engine-v1.51.0] - 2026-07-03
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
modules: @json($moduleSettings ?? []),
|
||||
moduleAssets: @json($moduleAssets ?? []),
|
||||
pluginAssets: @json($pluginAssets ?? []),
|
||||
bundleUrls: @json($bundleUrls ?? null),
|
||||
activeModules: @json($activeModulesMeta ?? []),
|
||||
activePlugins: @json($activePluginsMeta ?? []),
|
||||
appConfig: @json($appConfig ?? []),
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
modules: @json($moduleSettings ?? []),
|
||||
moduleAssets: @json($moduleAssets ?? []),
|
||||
pluginAssets: @json($pluginAssets ?? []),
|
||||
bundleUrls: @json($bundleUrls ?? null),
|
||||
activeModules: @json($activeModulesMeta ?? []),
|
||||
activePlugins: @json($activePluginsMeta ?? []),
|
||||
appConfig: @json($appConfig ?? []),
|
||||
|
||||
@@ -870,6 +870,10 @@ if (isset($_GET['ajax_action'])) {
|
||||
<span>레이아웃 미리보기 정리</span>
|
||||
<span class="text-[10px] opacity-60">(layout-previews:cleanup)</span>
|
||||
</button>
|
||||
<button onclick="runCommand('ext-bundles:cleanup')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white text-xs font-medium rounded transition-colors">
|
||||
<span>확장 번들 정리</span>
|
||||
<span class="text-[10px] opacity-60">(ext-bundles:cleanup)</span>
|
||||
</button>
|
||||
<button onclick="runCommand('geoip:update')" class="inline-flex items-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded transition-colors">
|
||||
<span>GeoIP DB 갱신</span>
|
||||
<span class="text-[10px] opacity-60">(geoip:update)</span>
|
||||
|
||||
@@ -125,6 +125,12 @@ Route::group([], function () {
|
||||
|
||||
// 모듈 에셋 서빙 API
|
||||
Route::prefix('modules')->group(function () {
|
||||
// 활성 모듈 프론트엔드 IIFE/CSS 병합 번들 (개별 assets 라우트보다 위에 명시 등록)
|
||||
Route::get('bundle.js', [PublicModuleController::class, 'serveBundleJs'])
|
||||
->name('api.public.modules.bundle.js');
|
||||
Route::get('bundle.css', [PublicModuleController::class, 'serveBundleCss'])
|
||||
->name('api.public.modules.bundle.css');
|
||||
|
||||
Route::get('assets/{identifier}/{path}', [PublicModuleController::class, 'serveAsset'])
|
||||
->where('path', '.*')
|
||||
->name('api.public.modules.assets');
|
||||
@@ -140,6 +146,12 @@ Route::group([], function () {
|
||||
|
||||
// 플러그인 에셋 서빙 API
|
||||
Route::prefix('plugins')->group(function () {
|
||||
// 활성 플러그인 프론트엔드 IIFE/CSS 병합 번들 (개별 assets 라우트보다 위에 명시 등록)
|
||||
Route::get('bundle.js', [PublicPluginController::class, 'serveBundleJs'])
|
||||
->name('api.public.plugins.bundle.js');
|
||||
Route::get('bundle.css', [PublicPluginController::class, 'serveBundleCss'])
|
||||
->name('api.public.plugins.bundle.css');
|
||||
|
||||
Route::get('assets/{identifier}/{path}', [PublicPluginController::class, 'serveAsset'])
|
||||
->where('path', '.*')
|
||||
->name('api.public.plugins.assets');
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api\Public;
|
||||
|
||||
use App\Services\ExtensionBundleService;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 확장 프론트엔드 병합 번들 서빙 엔드포인트 Feature 테스트
|
||||
*
|
||||
* /api/{modules,plugins}/bundle.{js,css} 의 응답 계약(Content-Type, ETag,
|
||||
* 304 Not Modified, 빈 번들 처리)을 검증한다. ExtensionBundleService 를
|
||||
* 컨테이너에 mock 바인딩해 활성 확장 조합에 의존하지 않고 컨트롤러 결선을 검증한다.
|
||||
*/
|
||||
class ExtensionBundleServingTest extends TestCase
|
||||
{
|
||||
private string $fixtureDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->fixtureDir = storage_path('framework/testing/ext-bundle-serving');
|
||||
File::ensureDirectoryExists($this->fixtureDir);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (is_dir($this->fixtureDir)) {
|
||||
foreach (glob($this->fixtureDir.'/*') as $f) {
|
||||
@unlink($f);
|
||||
}
|
||||
@rmdir($this->fixtureDir);
|
||||
}
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* ExtensionBundleService 를 지정 경로를 반환하도록 mock 바인딩한다.
|
||||
*/
|
||||
private function bindBundleService(string $returnPath): void
|
||||
{
|
||||
$mock = Mockery::mock(ExtensionBundleService::class);
|
||||
$mock->shouldReceive('getCurrentVersion')->andReturn(777);
|
||||
$mock->shouldReceive('getBundleFilePath')->andReturn($returnPath);
|
||||
$this->app->instance(ExtensionBundleService::class, $mock);
|
||||
}
|
||||
|
||||
public function test_module_bundle_js_serves_merged_file_with_etag(): void
|
||||
{
|
||||
$path = $this->fixtureDir.'/module.777.js';
|
||||
File::put($path, "(function(){window.A=1})()\n;\n(function(){window.B=2})()");
|
||||
$this->bindBundleService($path);
|
||||
|
||||
$response = $this->get('/api/modules/bundle.js?v=777');
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertStringStartsWith('text/javascript', $response->headers->get('Content-Type'));
|
||||
$response->assertHeader('ETag');
|
||||
$this->assertStringContainsString('window.A=1', $response->streamedContent() ?? $response->getContent());
|
||||
$this->assertStringContainsString("\n;\n", $response->streamedContent() ?? $response->getContent());
|
||||
}
|
||||
|
||||
public function test_bundle_returns_304_on_matching_etag(): void
|
||||
{
|
||||
$path = $this->fixtureDir.'/plugin.777.js';
|
||||
File::put($path, '(function(){})()');
|
||||
$this->bindBundleService($path);
|
||||
|
||||
$first = $this->get('/api/plugins/bundle.js?v=777');
|
||||
$first->assertOk();
|
||||
$etag = $first->headers->get('ETag');
|
||||
$this->assertNotNull($etag);
|
||||
|
||||
$second = $this->get('/api/plugins/bundle.js?v=777', ['If-None-Match' => $etag]);
|
||||
$second->assertStatus(304);
|
||||
}
|
||||
|
||||
public function test_empty_bundle_returns_empty_ok_response(): void
|
||||
{
|
||||
// 활성 global 에셋이 없으면 서비스가 빈 경로 반환 → 컨트롤러 빈 200
|
||||
$this->bindBundleService('');
|
||||
|
||||
$response = $this->get('/api/modules/bundle.js?v=777');
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertStringStartsWith('text/javascript', $response->headers->get('Content-Type'));
|
||||
// 빈 번들은 일반 Response(스트림 아님) — getContent 로 빈 본문 확인
|
||||
$this->assertSame('', $response->getContent());
|
||||
}
|
||||
|
||||
public function test_module_bundle_css_serves_with_css_content_type(): void
|
||||
{
|
||||
$path = $this->fixtureDir.'/module.777.css';
|
||||
File::put($path, '.a{color:red}');
|
||||
$this->bindBundleService($path);
|
||||
|
||||
$response = $this->get('/api/modules/bundle.css?v=777');
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertStringStartsWith('text/css', $response->headers->get('Content-Type'));
|
||||
}
|
||||
|
||||
public function test_bundle_routes_do_not_collide_with_asset_route(): void
|
||||
{
|
||||
// bundle.js 는 assets/{identifier}/{path} 로 매칭되면 안 됨 (정적 세그먼트 우선)
|
||||
$path = $this->fixtureDir.'/module.777.js';
|
||||
File::put($path, '(function(){window.BUNDLE=1})()');
|
||||
$this->bindBundleService($path);
|
||||
|
||||
$response = $this->get('/api/modules/bundle.js?v=777');
|
||||
|
||||
$response->assertOk();
|
||||
// 개별 에셋 서빙(serveAsset)이 아니라 번들 서빙(serveBundleJs)이 응답
|
||||
$this->assertStringContainsString('window.BUNDLE=1', $response->streamedContent() ?? $response->getContent());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* E2E: 확장 프론트엔드 병합 번들 로딩 회귀 검증
|
||||
*
|
||||
* @scenario extension-bundle-loading
|
||||
* @effects bundle_js_requested_once_per_type, individual_iife_requests_absent,
|
||||
* gdpr_interceptor_still_active, extension_handlers_registered
|
||||
*
|
||||
* 배경: 활성 모듈/플러그인 IIFE 를 서버측에서 종류별 1개 번들로 병합 서빙한다.
|
||||
* 실제 페이지 로드 시 (1) 개별 `*.iife.js` 요청이 사라지고 `bundle.js` 로 대체되는지,
|
||||
* (2) gdpr preblocker 가 여전히 유효한지(병합 후 race 회귀 없음), (3) 확장 자가등록
|
||||
* (핸들러/레지스트리)이 정상 동작하는지 실측한다.
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('확장 병합 번들 로딩', () => {
|
||||
test('@smoke 홈페이지 로드 시 개별 iife 대신 병합 번들이 요청된다', async ({ page }) => {
|
||||
const requestedUrls: string[] = [];
|
||||
page.on('request', (req) => {
|
||||
requestedUrls.push(req.url());
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle', { timeout: 30_000 });
|
||||
|
||||
const moduleBundle = requestedUrls.filter((u) => /\/api\/modules\/bundle\.js/.test(u));
|
||||
const pluginBundle = requestedUrls.filter((u) => /\/api\/plugins\/bundle\.js/.test(u));
|
||||
|
||||
// 활성 확장이 있으면 번들이 요청되고, 종류별로 1건씩만 나가야 한다
|
||||
const individualIife = requestedUrls.filter((u) => /\/api\/(modules|plugins)\/assets\/.*\.iife\.js/.test(u));
|
||||
|
||||
// 개별 iife 직접 요청은 0건 (번들로 대체)
|
||||
expect(individualIife, `개별 iife 요청이 남아있음: ${individualIife.join(', ')}`).toHaveLength(0);
|
||||
|
||||
// 번들이 요청됐다면 종류별 최대 1건 (중복 가드)
|
||||
expect(moduleBundle.length, '모듈 번들 중복 요청').toBeLessThanOrEqual(1);
|
||||
expect(pluginBundle.length, '플러그인 번들 중복 요청').toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('@smoke 병합 번들 응답이 정상(200/304)이고 same-origin 이다', async ({ page }) => {
|
||||
const bundleResponses: { url: string; status: number }[] = [];
|
||||
page.on('response', (res) => {
|
||||
if (/\/api\/(modules|plugins)\/bundle\.(js|css)/.test(res.url())) {
|
||||
bundleResponses.push({ url: res.url(), status: res.status() });
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle', { timeout: 30_000 });
|
||||
|
||||
for (const r of bundleResponses) {
|
||||
expect([200, 304], `번들 응답 비정상: ${r.url} → ${r.status}`).toContain(r.status);
|
||||
// same-origin (/api/...) — CDN/외부 origin 금지 (gdpr preblocker 자기차단 방지)
|
||||
const origin = new URL(page.url()).origin;
|
||||
expect(r.url.startsWith(origin), `번들 URL 이 same-origin 아님: ${r.url}`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('@smoke 확장 병합 로드 후 페이지가 정상 렌더된다 (자가등록 계약)', async ({ page }) => {
|
||||
const consoleErrors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
consoleErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle', { timeout: 30_000 });
|
||||
|
||||
// 홈 네비게이션이 렌더되면 템플릿 엔진 + 확장 자가등록이 정상 작동한 것
|
||||
await expect(page.getByTestId('nav-home')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// 번들 파싱 에러(ASI 경계 붕괴)가 있으면 "Unexpected token" 등 콘솔 에러가 뜬다
|
||||
const parseErrors = consoleErrors.filter((e) =>
|
||||
/Unexpected token|SyntaxError|is not defined|Unknown action handler/i.test(e),
|
||||
);
|
||||
expect(parseErrors, `번들 실행 관련 콘솔 에러: ${parseErrors.join(' | ')}`).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,307 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Services\ExtensionBundleService;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* ExtensionBundleService 단위 테스트
|
||||
*
|
||||
* 활성 확장 IIFE/CSS 의 priority 정렬, `\n;\n` 구분자 병합, sourceMappingURL
|
||||
* 처리, 확장별 fault tolerance, 캐시 파일 생성/정리를 검증한다.
|
||||
*/
|
||||
class ExtensionBundleServiceTest extends TestCase
|
||||
{
|
||||
private string $fixtureDir;
|
||||
|
||||
private ModuleManager $moduleManager;
|
||||
|
||||
private PluginManager $pluginManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->fixtureDir = storage_path('framework/testing/ext-bundle-fixtures');
|
||||
File::ensureDirectoryExists($this->fixtureDir);
|
||||
|
||||
$this->moduleManager = Mockery::mock(ModuleManager::class);
|
||||
$this->pluginManager = Mockery::mock(PluginManager::class);
|
||||
|
||||
// 번들 캐시 디렉토리 초기화 (테스트 격리)
|
||||
$bundleDir = storage_path('app/ext-bundles');
|
||||
if (is_dir($bundleDir)) {
|
||||
foreach (glob($bundleDir.'/*.{js,css}', GLOB_BRACE) as $f) {
|
||||
@unlink($f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (is_dir($this->fixtureDir)) {
|
||||
foreach (glob($this->fixtureDir.'/*') as $f) {
|
||||
@unlink($f);
|
||||
}
|
||||
@rmdir($this->fixtureDir);
|
||||
}
|
||||
|
||||
$bundleDir = storage_path('app/ext-bundles');
|
||||
if (is_dir($bundleDir)) {
|
||||
foreach (glob($bundleDir.'/*.{js,css}', GLOB_BRACE) as $f) {
|
||||
@unlink($f);
|
||||
}
|
||||
}
|
||||
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정 내용으로 fixture 에셋 파일을 만들고 절대 경로를 반환한다.
|
||||
*/
|
||||
private function writeFixture(string $name, string $content): string
|
||||
{
|
||||
$path = $this->fixtureDir.'/'.$name;
|
||||
File::put($path, $content);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* hasAssets/getAssetLoadingConfig/getBuiltAssetAbsolutePaths/getIdentifier 를
|
||||
* 노출하는 가짜 확장 인스턴스를 만든다.
|
||||
*/
|
||||
private function fakeExtension(string $identifier, int $priority, ?string $jsPath, ?string $cssPath, string $strategy = 'global'): object
|
||||
{
|
||||
$ext = Mockery::mock();
|
||||
$ext->shouldReceive('hasAssets')->andReturn(true);
|
||||
$ext->shouldReceive('getIdentifier')->andReturn($identifier);
|
||||
$ext->shouldReceive('getAssetLoadingConfig')->andReturn([
|
||||
'strategy' => $strategy,
|
||||
'priority' => $priority,
|
||||
'dependencies' => [],
|
||||
]);
|
||||
|
||||
$paths = [];
|
||||
if ($jsPath !== null) {
|
||||
$paths['js'] = $jsPath;
|
||||
}
|
||||
if ($cssPath !== null) {
|
||||
$paths['css'] = $cssPath;
|
||||
}
|
||||
$ext->shouldReceive('getBuiltAssetAbsolutePaths')->andReturn($paths);
|
||||
|
||||
return $ext;
|
||||
}
|
||||
|
||||
private function service(): ExtensionBundleService
|
||||
{
|
||||
return new ExtensionBundleService($this->moduleManager, $this->pluginManager);
|
||||
}
|
||||
|
||||
public function test_orders_global_assets_by_priority_ascending(): void
|
||||
{
|
||||
$a = $this->writeFixture('a.js', '(function(){})()');
|
||||
$b = $this->writeFixture('b.js', '(function(){})()');
|
||||
$c = $this->writeFixture('c.js', '(function(){})()');
|
||||
|
||||
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
||||
'ext-c' => $this->fakeExtension('ext-c', 30, $c, null),
|
||||
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
|
||||
'ext-b' => $this->fakeExtension('ext-b', 20, $b, null),
|
||||
]);
|
||||
|
||||
$ordered = $this->service()->getOrderedGlobalAssetPaths('module');
|
||||
|
||||
$this->assertSame(['ext-a', 'ext-b', 'ext-c'], array_keys($ordered));
|
||||
}
|
||||
|
||||
public function test_skips_non_global_strategy(): void
|
||||
{
|
||||
$g = $this->writeFixture('g.js', '(function(){})()');
|
||||
$l = $this->writeFixture('l.js', '(function(){})()');
|
||||
|
||||
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
||||
'ext-global' => $this->fakeExtension('ext-global', 100, $g, null, 'global'),
|
||||
'ext-layout' => $this->fakeExtension('ext-layout', 100, $l, null, 'layout'),
|
||||
]);
|
||||
|
||||
$ordered = $this->service()->getOrderedGlobalAssetPaths('module');
|
||||
|
||||
$this->assertArrayHasKey('ext-global', $ordered);
|
||||
$this->assertArrayNotHasKey('ext-layout', $ordered);
|
||||
}
|
||||
|
||||
public function test_js_bundle_joins_iife_with_semicolon_newline_separator(): void
|
||||
{
|
||||
// 세미콜론 없이 끝나는 IIFE 2개 (ecommerce 형태)
|
||||
$a = $this->writeFixture('a.js', '(function(){window.A=1})()');
|
||||
$b = $this->writeFixture('b.js', '(function(){window.B=2})()');
|
||||
|
||||
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
||||
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
|
||||
'ext-b' => $this->fakeExtension('ext-b', 20, $b, null),
|
||||
]);
|
||||
|
||||
$js = $this->service()->buildJsBundle('module');
|
||||
|
||||
// 두 IIFE 가 모두 포함되고 `\n;\n` 구분자로 구분
|
||||
$this->assertStringContainsString('window.A=1', $js);
|
||||
$this->assertStringContainsString('window.B=2', $js);
|
||||
$this->assertStringContainsString("\n;\n", $js);
|
||||
// priority 순서 (a 가 b 보다 먼저)
|
||||
$this->assertLessThan(strpos($js, 'window.B=2'), strpos($js, 'window.A=1'));
|
||||
}
|
||||
|
||||
public function test_prod_strips_source_mapping_url(): void
|
||||
{
|
||||
$this->app['env'] = 'production';
|
||||
app()->detectEnvironment(fn () => 'production');
|
||||
|
||||
$a = $this->writeFixture('a.js', "(function(){})()\n//# sourceMappingURL=a.js.map");
|
||||
|
||||
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
||||
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
|
||||
]);
|
||||
|
||||
$js = $this->service()->buildJsBundle('module');
|
||||
|
||||
$this->assertStringNotContainsString('sourceMappingURL', $js);
|
||||
}
|
||||
|
||||
public function test_dev_rewrites_source_mapping_url_to_asset_serving_path(): void
|
||||
{
|
||||
// 기본 testing 환경 = 비프로덕션 → dev rewrite 경로
|
||||
$a = $this->writeFixture('a.js', "(function(){})()\n//# sourceMappingURL=a.js.map");
|
||||
|
||||
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
||||
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
|
||||
]);
|
||||
|
||||
$js = $this->service()->buildJsBundle('module');
|
||||
|
||||
$this->assertStringContainsString('sourceMappingURL=/api/modules/assets/ext-a/dist/js/a.js.map', $js);
|
||||
}
|
||||
|
||||
public function test_per_extension_fault_tolerance_skips_missing_file(): void
|
||||
{
|
||||
$good = $this->writeFixture('good.js', '(function(){window.GOOD=1})()');
|
||||
$missing = $this->fixtureDir.'/does-not-exist.js';
|
||||
|
||||
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
||||
'ext-missing' => $this->fakeExtension('ext-missing', 10, $missing, null),
|
||||
'ext-good' => $this->fakeExtension('ext-good', 20, $good, null),
|
||||
]);
|
||||
|
||||
$js = $this->service()->buildJsBundle('module');
|
||||
|
||||
// 없는 파일은 skip, 정상 확장은 여전히 포함 (번들 전체 붕괴 안 함)
|
||||
$this->assertStringContainsString('window.GOOD=1', $js);
|
||||
}
|
||||
|
||||
public function test_css_with_relative_url_is_excluded(): void
|
||||
{
|
||||
$safe = $this->writeFixture('safe.css', '.a{color:red}');
|
||||
$relative = $this->writeFixture('rel.css', '.b{background:url(./img/x.png)}');
|
||||
|
||||
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
||||
'ext-safe' => $this->fakeExtension('ext-safe', 10, null, $safe),
|
||||
'ext-rel' => $this->fakeExtension('ext-rel', 20, null, $relative),
|
||||
]);
|
||||
|
||||
$css = $this->service()->buildCssBundle('module');
|
||||
|
||||
$this->assertStringContainsString('.a{color:red}', $css);
|
||||
$this->assertStringNotContainsString('url(./img/x.png)', $css);
|
||||
}
|
||||
|
||||
public function test_empty_bundle_returns_empty_path(): void
|
||||
{
|
||||
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([]);
|
||||
|
||||
$path = $this->service()->getBundleFilePath('module', 'js', 12345);
|
||||
|
||||
$this->assertSame('', $path);
|
||||
}
|
||||
|
||||
public function test_prod_writes_versioned_cache_file_and_reuses_it(): void
|
||||
{
|
||||
$this->app['env'] = 'production';
|
||||
app()->detectEnvironment(fn () => 'production');
|
||||
|
||||
$a = $this->writeFixture('a.js', '(function(){window.A=1})()');
|
||||
// getActiveModules 는 두 번 호출될 수 있으므로 안정적으로 반환
|
||||
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([
|
||||
'ext-a' => $this->fakeExtension('ext-a', 10, $a, null),
|
||||
]);
|
||||
|
||||
$svc = $this->service();
|
||||
$path1 = $svc->getBundleFilePath('module', 'js', 999);
|
||||
|
||||
$this->assertNotSame('', $path1);
|
||||
$this->assertFileExists($path1);
|
||||
$this->assertStringContainsString('module.999.js', $path1);
|
||||
|
||||
// 같은 version 재요청 → 동일 파일 (캐시 히트)
|
||||
$path2 = $svc->getBundleFilePath('module', 'js', 999);
|
||||
$this->assertSame($path1, $path2);
|
||||
}
|
||||
|
||||
public function test_cleanup_removes_stale_version_bundles_only(): void
|
||||
{
|
||||
$bundleDir = storage_path('app/ext-bundles');
|
||||
File::ensureDirectoryExists($bundleDir);
|
||||
File::put($bundleDir.'/module.100.js', 'old');
|
||||
File::put($bundleDir.'/module.200.js', 'current');
|
||||
File::put($bundleDir.'/plugin.100.css', 'old');
|
||||
|
||||
$this->moduleManager->shouldReceive('getActiveModules')->andReturn([]);
|
||||
|
||||
$deleted = $this->service()->cleanupStaleBundles(200);
|
||||
|
||||
// version 200 외 파일만 삭제 (module.100.js, plugin.100.css = 2건)
|
||||
$this->assertSame(2, $deleted);
|
||||
$this->assertFileExists($bundleDir.'/module.200.js');
|
||||
$this->assertFileDoesNotExist($bundleDir.'/module.100.js');
|
||||
$this->assertFileDoesNotExist($bundleDir.'/plugin.100.css');
|
||||
}
|
||||
|
||||
public function test_clear_bundles_by_type(): void
|
||||
{
|
||||
$bundleDir = storage_path('app/ext-bundles');
|
||||
File::ensureDirectoryExists($bundleDir);
|
||||
File::put($bundleDir.'/module.100.js', 'm');
|
||||
File::put($bundleDir.'/plugin.100.js', 'p');
|
||||
|
||||
$deleted = $this->service()->clearBundles('module');
|
||||
|
||||
$this->assertSame(1, $deleted);
|
||||
$this->assertFileDoesNotExist($bundleDir.'/module.100.js');
|
||||
$this->assertFileExists($bundleDir.'/plugin.100.js');
|
||||
}
|
||||
|
||||
public function test_plugin_bundle_orders_by_priority_gdpr_first_when_lowest(): void
|
||||
{
|
||||
// gdpr 가 priority 50 으로 최상단(제약 1 회귀 가드) — 이름 하드코딩 아닌 선언 결과
|
||||
$gdpr = $this->writeFixture('gdpr.js', '(function(){window.GDPR=1})()');
|
||||
$other = $this->writeFixture('other.js', '(function(){window.OTHER=1})()');
|
||||
|
||||
$this->pluginManager->shouldReceive('getActivePlugins')->andReturn([
|
||||
'sirsoft-other' => $this->fakeExtension('sirsoft-other', 100, $other, null),
|
||||
'sirsoft-gdpr' => $this->fakeExtension('sirsoft-gdpr', 50, $gdpr, null),
|
||||
]);
|
||||
|
||||
$ordered = $this->service()->getOrderedGlobalAssetPaths('plugin');
|
||||
|
||||
$this->assertSame('sirsoft-gdpr', array_key_first($ordered));
|
||||
|
||||
$js = $this->service()->buildJsBundle('plugin');
|
||||
$this->assertLessThan(strpos($js, 'window.OTHER=1'), strpos($js, 'window.GDPR=1'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# audit:allow test-scenario-coverage reason: 서버측 확장 번들 병합 도입 회귀 가드. 입력 axis(활성 조합·환경·에셋 종류)의 cross-product 는 test_files 의 Unit/Feature 가 cover 하고, effects 는 병합 계약(순서/구분자/소스맵/캐시/폴백)과 브라우저 실측(요청수/gdpr race)을 enumerate.
|
||||
|
||||
feature: 확장(모듈/플러그인) 프론트엔드 IIFE/CSS 서버측 번들 병합
|
||||
|
||||
description: |
|
||||
활성 모듈/플러그인의 global 전략 프론트엔드 에셋을 타입별로 하나의 번들로
|
||||
서버에서 병합해 서빙, 확장 수에 비례하던 HTTP 요청을 종류별 1건으로 줄인다.
|
||||
각 확장 IIFE 자가등록 계약은 유지(priority 순 물리 병합 = 실행 순서).
|
||||
|
||||
핵심 동작:
|
||||
- ExtensionBundleService 가 getOrderedGlobalAssetPaths(uasort priority) →
|
||||
buildJsBundle(`\n;\n` join, sourceMappingURL 처리) → getBundleFilePath(캐시)
|
||||
- PublicModule/PluginController::serveBundle{Js,Css} → fileResponse(ETag/304)
|
||||
- blade bundleUrls → ModuleAssetLoader.loadBundle(module) → loadBundle(plugin)
|
||||
- 확장 캐시 버전 bump 시 새 파일명 자동 무효화, ext-bundles:cleanup GC
|
||||
- bundleUrls 부재 시 개별 로딩 폴백(회귀 안전)
|
||||
|
||||
axes:
|
||||
ext_type: [module, plugin]
|
||||
asset_kind: [js, css, both]
|
||||
active_combo: [none, one, multiple]
|
||||
strategy: [global, layout, lazy]
|
||||
environment: [production, non_production]
|
||||
version_state: [first_request, cache_hit, version_bumped]
|
||||
source_map_state: [present, absent]
|
||||
css_url_state: [no_url, absolute_url, relative_url]
|
||||
file_state: [all_present, one_missing]
|
||||
priority_order: [ascending, gdpr_lowest]
|
||||
bundle_urls_present: [present, absent_fallback]
|
||||
|
||||
exclusions:
|
||||
- { strategy: layout, active_combo: none, reason: "layout/lazy 전략은 번들 대상 아님 — 개별 레이아웃 처리" }
|
||||
- { active_combo: none, version_state: cache_hit, reason: "활성 에셋 0건이면 캐시 파일을 만들지 않음 (빈 경로)" }
|
||||
|
||||
effects:
|
||||
- ordered_by_priority_ascending_only_no_name_hardcode
|
||||
- non_global_strategy_excluded_from_bundle
|
||||
- js_iife_joined_with_semicolon_newline_separator
|
||||
- prod_strips_source_mapping_url
|
||||
- dev_rewrites_source_mapping_url_to_asset_serving_path
|
||||
- per_extension_fault_tolerance_skips_missing_file_keeps_rest
|
||||
- css_with_relative_url_excluded_from_bundle
|
||||
- empty_bundle_returns_empty_path_and_empty_ok_response
|
||||
- prod_writes_versioned_cache_file_and_reuses_on_same_version
|
||||
- non_production_rebuilds_every_request_no_disk_cache
|
||||
- cleanup_removes_stale_version_bundles_only
|
||||
- clear_bundles_by_type_scopes_to_prefix
|
||||
- plugin_bundle_gdpr_first_when_priority_lowest
|
||||
- bundle_endpoint_serves_with_etag_and_304
|
||||
- bundle_url_same_origin_only
|
||||
- loadbundle_single_script_single_link_append
|
||||
- loadbundle_dedup_guard_no_double_load
|
||||
- loadbundle_null_urls_noop
|
||||
- concat_iife_self_registration_preserved
|
||||
- missing_bundle_urls_falls_back_to_individual_loading
|
||||
- individual_iife_requests_absent_in_browser
|
||||
- gdpr_interceptor_still_active_after_merge
|
||||
- extension_handlers_registered_no_console_error
|
||||
|
||||
test_files:
|
||||
- tests/Unit/Services/ExtensionBundleServiceTest.php
|
||||
- tests/Feature/Api/Public/ExtensionBundleServingTest.php
|
||||
- resources/js/core/modules/__tests__/ModuleAssetLoader.test.ts
|
||||
- tests/Playwright/specs/extension-bundle-loading.spec.ts
|
||||
|
||||
validation:
|
||||
audit_rules:
|
||||
- extension-bundle-url-same-origin
|
||||
- extension-bundle-concat-separator
|
||||
- extension-bundle-asset-path-getter
|
||||
- clears-template-caches-no-file-delete
|
||||
Reference in New Issue
Block a user