KVE-2026-2191 대응에서 계획 안 세부를 자율 결정한 지점을 회귀 전수조사해 정합 결함 5건을 고쳤다. 넷은 같은 미출시 버전이 만든 자기교정이고, 마지막 하나는 7.0.10 및 그 이전부터 있던 결함이라 공개 CHANGELOG 에 항목을 넣었다. - 적중 통계의 레이아웃명이 항상 비어 화면별 표가 성립하지 않던 것 → 페이지 캐시 값에 레이아웃명을 함께 담고 적중 경로가 그것으로 귀속한다. 이전 버전이 문자열로만 저장한 항목은 그대로 읽어 배포 직후 살아 있는 캐시를 버리지 않는다. 인터페이스는 불변이고 코어 구현일 때만 항목 전체를 읽는다. - 통계 url 컬럼(255)이 캐시 키 URL(정규화 쿼리 최대 512바이트)보다 짧아 긴 주소의 기록이 엄격 모드에서 실패하고 그 예외를 서비스가 삼키던 것 → 768 로 확장(utf8mb4 인덱스 키 상한 3072바이트). down 은 잘리는 행을 먼저 지운다. - 경로당 변종 상한이 언어를 합산해 다국어 사이트의 실효 상한이 언어 수만큼 줄던 것 → 인덱스 항목이 url|locale 별이므로 경로·언어 조합 단위로 판정한다. - 그릴 게 없는 요청(미라우트 404·SEO 비활성)이 렌더 예산을 소모해, 캐시에 남지 않는 죽은 주소 재크롤이 정상 페이지의 예산을 태우던 것 → null 렌더는 차감을 환급한다. 렌더 도중 예외는 비용을 이미 치른 것이라 환급하지 않는다. - 병합 단계에서 건너뛴 확장이 있는 결과가 캐시로 굳어 버전 bump 전까지 그 확장 자산이 사라진 채 고정되던 것 → 병합 루프를 mergeJs/mergeCss 로 분리해 건너뛴 확장을 함께 돌려주고, 하나라도 있으면 캐시하지 않고 Log::error 로 남긴다. 캐시 없이 돌면 호출측이 매 요청 다시 병합하므로 원인이 사라지는 즉시 회복한다. 기본 로그 수준이 error 라 종전 per-extension warning 은 기록되지 않아 흔적이 없었다. 회귀 테스트 8건은 red 확인 후 green 으로 전환했고, 변경 심볼의 소비자 모집단(테스트 클래스 9종)을 프로세스 분할로 재실행해 189건 통과를 확인했다.
866 lines
37 KiB
PHP
866 lines
37 KiB
PHP
<?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 App\Support\AssetCssUrlRewriter;
|
|
use App\Support\AssetUrl;
|
|
use Illuminate\Contracts\Cache\LockTimeoutException;
|
|
use Illuminate\Support\Facades\Cache;
|
|
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).
|
|
* 소실 판정만 선언 축 게터(`getDeclaredAssetAbsolutePaths()`)를 쓴다 — 그 게터는
|
|
* `file_exists()` 게이트를 타지 않아 "선언은 있는데 파일이 없다" 를 셀 수 있다.
|
|
*
|
|
* @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';
|
|
|
|
/**
|
|
* 원자적 쓰기 임시 파일(`*.tmp.{pid}`)을 잔존물로 보는 나이 (초).
|
|
*
|
|
* pid 는 재사용되므로 "그 pid 가 살아 있는가" 로는 진행 중 여부를 판정할 수 없다.
|
|
*/
|
|
private const TEMP_BUNDLE_STALE_SECONDS = 600;
|
|
|
|
/**
|
|
* 캐시 미스 빌드를 (type, kind, version) 단위로 수렴시키는 잠금 키 접두사.
|
|
*/
|
|
private const BUILD_LOCK_PREFIX = 'ext-bundles.build.';
|
|
|
|
/**
|
|
* 빌드 잠금의 보유 상한 (초). 좀비 잠금 방지용이며 실제 빌드는 밀리초 단위다.
|
|
*/
|
|
private const BUILD_LOCK_TTL_SECONDS = 30;
|
|
|
|
/**
|
|
* 다른 프로세스의 빌드를 기다리는 상한 (초). 초과하면 잠금 없이 각자 빌드한다.
|
|
*/
|
|
private const BUILD_LOCK_WAIT_SECONDS = 5;
|
|
|
|
/**
|
|
* 서비스 주입
|
|
*
|
|
* @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).
|
|
*
|
|
* `cssRelPath` 는 확장 루트 기준 상대 경로다 — 병합 시 CSS 안의 상대 참조를 그 CSS 가
|
|
* 놓인 위치 기준으로 풀어야 하는데, 절대 경로만으로는 확장 루트를 되짚을 수 없다.
|
|
*
|
|
* @param string $type 'module' | 'plugin'
|
|
* @return array<string, array{jsAbsPath: ?string, cssAbsPath: ?string, cssRelPath: ?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;
|
|
}
|
|
|
|
// CSS 안의 상대 참조를 풀려면 그 CSS 가 확장 안에서 **어디에 놓였는지**가 필요하다.
|
|
// 절대 경로만으로는 확장 루트를 되짚을 수 없으므로 선언된 상대 경로를 함께 싣는다.
|
|
$cssRelPath = $extension->getBuiltAssetPaths()['css'] ?? null;
|
|
|
|
$ordered[$extension->getIdentifier()] = [
|
|
'jsAbsPath' => $jsAbsPath,
|
|
'cssAbsPath' => $cssAbsPath,
|
|
'cssRelPath' => $cssRelPath,
|
|
'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
|
|
{
|
|
return $this->mergeBundle($type, 'js')['content'];
|
|
}
|
|
|
|
/**
|
|
* 확장 타입의 CSS 번들 문자열을 생성합니다.
|
|
*
|
|
* priority 순으로 각 CSS 파일을 읽어 `\n` 구분자로 이어붙인다. CSS 안의 상대
|
|
* `url(...)`·`@import` 참조는 그 확장의 절대 자산 URL 로 치환한다 — 병합본의 주소는
|
|
* 어느 확장의 dist 디렉토리도 아니라 상대 해석이 반드시 어긋나기 때문이다.
|
|
*
|
|
* 치환은 개별 자산 서빙(ServesRewritableCssAssets)과 같은 규칙(AssetCssUrlRewriter)을
|
|
* 쓴다. 두 경로가 서로 다른 코드로 갈라지면 한쪽만 고쳐진 채 남는다.
|
|
*
|
|
* @param string $type 'module' | 'plugin'
|
|
* @return string 병합된 CSS (활성 global 에셋이 없으면 빈 문자열)
|
|
*/
|
|
public function buildCssBundle(string $type): string
|
|
{
|
|
return $this->mergeBundle($type, 'css')['content'];
|
|
}
|
|
|
|
/**
|
|
* 캐시된 번들 파일의 절대 경로를 반환합니다(없으면 build → 원자적 write).
|
|
*
|
|
* 파일명에 version 을 포함(`{type}.{version}.{js|css}`)하므로 활성 조합이
|
|
* 바뀌어 version 이 bump 되면 새 파일명으로 자연 무효화된다. 프로덕션에서만
|
|
* 디스크 캐시하며, 비프로덕션(dev/watch)에서는 캐시하지 않고 매 요청 build 해
|
|
* rebuild 를 즉시 반영한다.
|
|
*
|
|
* 프로덕션에서 **캐시 존재 확인이 빌드보다 먼저** 온다. 캐시 키는 인자만으로
|
|
* 계산되므로 빌드가 필요 없는데, 빌드를 앞세우면 캐시가 있어도 요청마다 활성 확장을
|
|
* 열거하고 산출물을 전부 읽는다. 응답은 정상 200 이라 그 반복은 타이밍 말고는 드러나지
|
|
* 않고, 원본이 사라지는 순간에는 멀쩡한 캐시를 두고 빈 경로가 반환되어 503 이 된다.
|
|
*
|
|
* @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
|
|
{
|
|
$relativeName = $this->bundleFileName($type, $kind, $version);
|
|
|
|
// 디스크 캐시는 **최적화**다 — 쓰기 실패가 공개 엔드포인트의 500 이 되면 안 된다.
|
|
// `ext-bundles` 디스크는 `throw => true` 라 권한 문제(uid 독점 0700 등)에서
|
|
// `UnableToWriteFile` 이 그대로 올라오고, 그러면 모든 확장의 프론트엔드 JS/CSS 가
|
|
// 통째로 나가지 못한다. 병합 결과는 이미 메모리에 있으므로 그것을 그대로 응답하면
|
|
// 화면은 정상이다 (커밋 63a30ab29 의 AbstractCacheDriver fail-soft 와 같은 원칙).
|
|
try {
|
|
$storage = $this->bundleStorage();
|
|
|
|
// 비프로덕션은 캐시하지 않고 임시 파일로 매번 build → rebuild 즉시 반영
|
|
if (! app()->environment('production')) {
|
|
$content = $this->buildBundleContent($type, $kind);
|
|
|
|
// 병합할 에셋이 하나도 없으면 파일을 만들지 않는다(호출측이 빈 문자열로 판단).
|
|
if ($content === '') {
|
|
return '';
|
|
}
|
|
|
|
return $this->writeAtomically($storage, $relativeName, $content, cache: false);
|
|
}
|
|
|
|
// 프로덕션: 동일 version 캐시가 있으면 빌드 없이 그대로 사용
|
|
if ($storage->exists('', $relativeName)) {
|
|
return $storage->getBasePath('').'/'.$relativeName;
|
|
}
|
|
|
|
return $this->buildAndCacheOnce($storage, $type, $kind, $version, $relativeName);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('확장 번들 디스크 캐시 실패 — 메모리 병합 결과로 서빙합니다', [
|
|
'type' => $type,
|
|
'kind' => $kind,
|
|
'version' => $version,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 캐시 미스에서 한 번만 병합해 캐시 파일을 만들고 그 절대 경로를 반환합니다.
|
|
*
|
|
* 같은 (type, kind, version) 의 동시 요청은 잠금으로 하나에 수렴시킨다 — 버전 교체
|
|
* 직후에는 캐시가 없는 상태로 요청이 몰리고, 각자 병합하면 그 비용이 워커 수만큼 곱해진다.
|
|
* 잠금은 **최적화**이므로 대기 초과·저장소 장애는 실패로 바꾸지 않고 각자 빌드로 폴백한다
|
|
* (정적 게시 잠금 `ext-static.publish.{v}` 와 같은 저장소·같은 규율이며 키가 달라 자기
|
|
* 교착이 없다).
|
|
*
|
|
* 병합 결과가 비어 있어도 선언한 산출물이 **전부 존재하면**(또는 선언이 0이면) 0바이트
|
|
* 캐시 파일을 만든다. 그래야 정적 게시 대상이 되어 방문자가 웹서버에서 직접 받는다 —
|
|
* 만들지 않으면 그 구성의 모든 페이지 로드가 PHP 를 거친다. 캐시하지 않는 것은 둘이다 —
|
|
* 선언한 산출물이 **소실**된 경우(호출측의 503 판정을 그대로 유지한다)와 병합 단계에서
|
|
* 확장을 **건너뛴** 경우(그 상태가 굳지 않도록 매 요청 재시도에 맡긴다).
|
|
*
|
|
* @param CoreStorageDriver $storage 번들 디스크 스토리지
|
|
* @param string $type 'module' | 'plugin'
|
|
* @param string $kind 'js' | 'css'
|
|
* @param int $version 확장 캐시 버전
|
|
* @param string $relativeName 캐시 파일명
|
|
* @return string 캐시 파일의 절대 경로 (캐시하지 않았으면 빈 문자열)
|
|
*/
|
|
private function buildAndCacheOnce(
|
|
CoreStorageDriver $storage,
|
|
string $type,
|
|
string $kind,
|
|
int $version,
|
|
string $relativeName
|
|
): string {
|
|
$lock = null;
|
|
$acquired = false;
|
|
|
|
try {
|
|
$lock = Cache::lock(self::BUILD_LOCK_PREFIX."{$type}.{$kind}.{$version}", self::BUILD_LOCK_TTL_SECONDS);
|
|
$acquired = (bool) $lock->block(self::BUILD_LOCK_WAIT_SECONDS);
|
|
} catch (LockTimeoutException $e) {
|
|
// 대기 초과는 정상적인 경합이다 — 흔적만 남기고 각자 빌드한다.
|
|
Log::debug('확장 번들 빌드 잠금 대기 초과 — 잠금 없이 병합합니다', [
|
|
'type' => $type,
|
|
'kind' => $kind,
|
|
'version' => $version,
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
// 저장소가 잠금을 제공하지 못한다(드라이버 미지원, 캐시 디렉토리 권한 등).
|
|
// 번들 서빙 자체를 막지는 않으므로 사유만 남기고 계속한다.
|
|
Log::warning('확장 번들 빌드 잠금 획득 불가 — 잠금 없이 병합합니다', [
|
|
'type' => $type,
|
|
'kind' => $kind,
|
|
'version' => $version,
|
|
'store' => config('cache.default'),
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
|
|
try {
|
|
// 기다리는 동안 다른 프로세스가 완성했을 수 있다 — 병합 전에 다시 본다.
|
|
if ($storage->exists('', $relativeName)) {
|
|
return $storage->getBasePath('').'/'.$relativeName;
|
|
}
|
|
|
|
['content' => $content, 'skipped' => $skipped] = $this->mergeBundle($type, $kind);
|
|
|
|
// 건너뛴 확장이 있으면 캐시하지 않는다 — 파일은 존재·판독 가능한데 읽기·치환이
|
|
// 실패한 상태가 캐시로 굳으면 버전 bump 전까지 그 확장 자산이 사라진 채 고정된다.
|
|
// 캐시 없이 돌아가면 호출측이 매 요청 다시 병합하므로 원인이 사라지는 순간 회복한다.
|
|
// 출하 기본 로그 수준이 error 라 warning 은 기록되지 않는다 — 이 통지가 유일한 흔적이다.
|
|
if ($skipped !== []) {
|
|
Log::error('확장 번들 캐시 보류 — 병합 단계에서 건너뛴 확장이 있어 캐시하지 않습니다', [
|
|
'type' => $type,
|
|
'kind' => $kind,
|
|
'version' => $version,
|
|
'skipped' => $skipped,
|
|
]);
|
|
|
|
return '';
|
|
}
|
|
|
|
// 비었는데 선언한 산출물이 소실이면 캐시하지 않는다 — 배포 중 dist 가 잠깐 빈
|
|
// 장애가 0바이트 캐시로 굳어 정상(빈 200)으로 위장되면 안 된다.
|
|
if ($content === '' && $this->findMissingDeclaredAssets($type, $kind) !== []) {
|
|
return '';
|
|
}
|
|
|
|
return $this->writeAtomically($storage, $relativeName, $content, cache: true);
|
|
} finally {
|
|
if ($acquired && $lock !== null) {
|
|
try {
|
|
$lock->release();
|
|
} catch (\Throwable $e) {
|
|
// 해제 실패는 TTL 이 정리한다 — 서빙에는 영향이 없다.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 번들을 서빙할 때 쓸 병합 결과를 반환합니다 (디스크 캐시 실패 시 메모리 폴백용).
|
|
*
|
|
* @param string $type 'module' | 'plugin'
|
|
* @param string $kind 'js' | 'css'
|
|
* @return string 병합 결과 (없으면 빈 문자열)
|
|
*/
|
|
public function buildBundleContent(string $type, string $kind): string
|
|
{
|
|
return $this->mergeBundle($type, $kind)['content'];
|
|
}
|
|
|
|
/**
|
|
* 병합 결과와 함께 **건너뛴 확장**을 돌려줍니다 (캐시 판정용).
|
|
*
|
|
* 캐시할지는 결과 문자열만으로 판정할 수 없다 — 파일은 존재·판독 가능한데 읽기나 치환이
|
|
* 실패해 건너뛴 확장은 결과에서 조용히 빠질 뿐이다. 그 상태가 캐시로 굳으면 버전 bump
|
|
* 전까지 그 확장 자산이 사라진 채 고정되므로, 캐시 경로는 건너뜀 여부를 함께 받는다.
|
|
*
|
|
* @param string $type 'module' | 'plugin'
|
|
* @param string $kind 'js' | 'css'
|
|
* @return array{content: string, skipped: list<string>} 병합 결과와 건너뛴 확장 식별자
|
|
*/
|
|
private function mergeBundle(string $type, string $kind): array
|
|
{
|
|
$merged = $kind === 'css' ? $this->mergeCss($type) : $this->mergeJs($type);
|
|
|
|
return [
|
|
'content' => implode($kind === 'css' ? "\n" : "\n;\n", $merged['segments']),
|
|
'skipped' => $merged['skipped'],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* JS 세그먼트를 priority 순으로 모읍니다.
|
|
*
|
|
* 확장별 fine-grained try/catch — 읽기 실패·처리 예외는 그 확장만 건너뛰고(`skipped`
|
|
* 에 기록) 나머지 병합을 지속한다. 한 확장의 실패가 번들 전체를 붕괴시키지 않는다.
|
|
*
|
|
* @param string $type 'module' | 'plugin'
|
|
* @return array{segments: list<string>, skipped: list<string>} 세그먼트와 건너뛴 확장 식별자
|
|
*/
|
|
private function mergeJs(string $type): array
|
|
{
|
|
$ordered = $this->getOrderedGlobalAssetPaths($type);
|
|
$isProduction = app()->environment('production');
|
|
$segments = [];
|
|
$skipped = [];
|
|
|
|
foreach ($ordered as $identifier => $paths) {
|
|
if (empty($paths['jsAbsPath'])) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$content = $this->readAssetSource($paths['jsAbsPath']);
|
|
|
|
if ($content === false) {
|
|
Log::warning('확장 JS 번들 병합: 파일 읽기 실패, 해당 확장 skip', [
|
|
'type' => $type,
|
|
'identifier' => $identifier,
|
|
'path' => $paths['jsAbsPath'],
|
|
]);
|
|
$skipped[] = (string) $identifier;
|
|
|
|
continue;
|
|
}
|
|
|
|
$segments[] = $this->processJsSourceMap($content, $type, $identifier, $isProduction);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('확장 JS 번들 병합 중 오류, 해당 확장 skip', [
|
|
'type' => $type,
|
|
'identifier' => $identifier,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
$skipped[] = (string) $identifier;
|
|
}
|
|
}
|
|
|
|
return ['segments' => $segments, 'skipped' => $skipped];
|
|
}
|
|
|
|
/**
|
|
* CSS 세그먼트를 priority 순으로 모읍니다.
|
|
*
|
|
* CSS 안의 상대 `url(...)`·`@import` 참조는 그 확장의 절대 자산 URL 로 치환한다 —
|
|
* 병합본의 주소는 어느 확장의 dist 디렉토리도 아니라 상대 해석이 반드시 어긋나기 때문이다.
|
|
* 치환은 개별 자산 서빙(ServesRewritableCssAssets)과 같은 규칙(AssetCssUrlRewriter)을 쓴다.
|
|
*
|
|
* @param string $type 'module' | 'plugin'
|
|
* @return array{segments: list<string>, skipped: list<string>} 세그먼트와 건너뛴 확장 식별자
|
|
*/
|
|
private function mergeCss(string $type): array
|
|
{
|
|
$ordered = $this->getOrderedGlobalAssetPaths($type);
|
|
$isProduction = app()->environment('production');
|
|
$typeSegment = $type === 'plugin' ? 'plugins' : 'modules';
|
|
$version = $this->getCurrentVersion();
|
|
$segments = [];
|
|
$skipped = [];
|
|
|
|
foreach ($ordered as $identifier => $paths) {
|
|
if (empty($paths['cssAbsPath'])) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$content = $this->readAssetSource($paths['cssAbsPath']);
|
|
|
|
if ($content === false) {
|
|
Log::warning('확장 CSS 번들 병합: 파일 읽기 실패, 해당 확장 skip', [
|
|
'type' => $type,
|
|
'identifier' => $identifier,
|
|
'path' => $paths['cssAbsPath'],
|
|
]);
|
|
$skipped[] = (string) $identifier;
|
|
|
|
continue;
|
|
}
|
|
|
|
// 상대 참조는 **치환**한다. 병합본의 주소(`/api/{type}/bundle.css` 또는 정적
|
|
// 게시본)는 어느 확장의 dist 디렉토리도 아니므로 상대 해석이 반드시 어긋나는데,
|
|
// 그 실패는 404 하나로만 나타나 서버 로그에 흔적이 없다.
|
|
//
|
|
// 종전에는 그런 CSS 를 가진 확장을 번들에서 통째로 제외했다. 그러나 번들 URL 이
|
|
// 내려오면 프론트는 개별 로딩을 아예 타지 않으므로(TemplateApp.loadExtensionAssets)
|
|
// 제외 = 그 확장의 스타일이 **하나도 적용되지 않음** 이었다. 주석이 말하던
|
|
// "개별 폴백" 은 bundleUrls 부재(구버전 blade) 경로에만 있다.
|
|
$content = AssetCssUrlRewriter::rewrite(
|
|
$content,
|
|
(string) ($paths['cssRelPath'] ?? ''),
|
|
fn (string $path): string => AssetUrl::extensionApiAsset(
|
|
$typeSegment,
|
|
$identifier,
|
|
$path,
|
|
$version
|
|
)
|
|
);
|
|
|
|
$segments[] = $this->processCssSourceMap($content, $isProduction);
|
|
} catch (\Throwable $e) {
|
|
Log::warning('확장 CSS 번들 병합 중 오류, 해당 확장 skip', [
|
|
'type' => $type,
|
|
'identifier' => $identifier,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
$skipped[] = (string) $identifier;
|
|
}
|
|
}
|
|
|
|
return ['segments' => $segments, 'skipped' => $skipped];
|
|
}
|
|
|
|
/**
|
|
* 확장 자산 원본을 읽습니다.
|
|
*
|
|
* 실패는 `false` 로 돌아오고 호출측이 그 확장을 건너뛴다. 별도 메서드인 이유는
|
|
* "존재·판독 가능한데 읽기가 실패하는" 상태를 테스트가 재현할 수 있어야 하기 때문이다 —
|
|
* 그 상태가 캐시로 굳는 것이 이 서비스가 막아야 할 결함이다.
|
|
*
|
|
* @param string $path 절대 경로
|
|
* @return string|false 파일 내용 (실패 시 false)
|
|
*/
|
|
protected function readAssetSource(string $path): string|false
|
|
{
|
|
return @file_get_contents($path);
|
|
}
|
|
|
|
/**
|
|
* 해당 타입에서 프론트엔드 에셋을 **선언한** 활성 확장 수를 반환합니다.
|
|
*
|
|
* 이 값은 **선언 축**이다 — 503 의 판정은 소실 축(`findMissingDeclaredAssets()`)이
|
|
* 한다. 선언 축만으로 "선언 > 0 && 병합 결과 0 = 장애" 로 등치하면, 산출물이 존재하되
|
|
* 비어 있는 정당한 상태(스타일이 비어 있는 확장)까지 배포 장애로 잡혀 그 확장만
|
|
* 설치된 기본 구성이 통째로 503 이 된다.
|
|
*
|
|
* 선언 축은 로그 컨텍스트(운영자가 보는 "선언한 확장이 몇 개인가")와 화면 진단이
|
|
* 근거로 삼는다.
|
|
*
|
|
* 판정은 **kind 별**이다 — js 만 선언한 확장이 있는 상태에서 css 번들이 비는 것은
|
|
* 정상이므로, 그 경우까지 장애로 보면 정상 구성이 503 이 된다.
|
|
*
|
|
* 근거는 manifest 의 `assets.{kind}.output` **선언**이며 산출물 파일의 존재를 보지
|
|
* 않는다. `getOrderedGlobalAssetPaths()` / `hasAssets()` / `getBuiltAssetPaths()` 는
|
|
* 전부 `file_exists()` 게이트를 타므로, 그 경로로 세면 "dist 가 잠깐 빔" 이 곧
|
|
* "선언 0" 이 되어 **막으려던 바로 그 상태가 정상(빈 200)으로 판정된다.** 선언과
|
|
* 산출은 다른 축이고, 이 메서드가 재는 것은 선언 축이다.
|
|
*
|
|
* @param string $type 'module' | 'plugin'
|
|
* @param string $kind 'js' | 'css'
|
|
* @return int 해당 kind 의 에셋을 선언한 활성 확장 수
|
|
*/
|
|
public function countAssetDeclaringExtensions(string $type, string $kind): int
|
|
{
|
|
try {
|
|
$extensions = $type === 'plugin'
|
|
? $this->pluginManager->getActivePlugins()
|
|
: $this->moduleManager->getActiveModules();
|
|
|
|
$declared = 0;
|
|
|
|
foreach ($extensions as $extension) {
|
|
// global 전략만 번들 대상 — 병합 대상 모집단과 동일한 필터를 쓴다
|
|
if (($extension->getAssetLoadingConfig()['strategy'] ?? 'global') !== 'global') {
|
|
continue;
|
|
}
|
|
|
|
if (! empty($extension->getAssets()[$kind]['output'] ?? null)) {
|
|
$declared++;
|
|
}
|
|
}
|
|
|
|
return $declared;
|
|
} catch (\Throwable $e) {
|
|
Log::warning('확장 에셋 선언 수 집계 실패', [
|
|
'type' => $type,
|
|
'kind' => $kind,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 선언된 산출물 중 **소실·판독 불가**한 것의 절대 경로 목록을 반환합니다.
|
|
*
|
|
* 503 의 근거는 선언이 아니라 이 소실 축이다. 선언만으로 판정하면 "선언됨 + 산출물이 존재하지만
|
|
* 0바이트"(스타일이 비어 있는 확장의 정당한 상태) 와 "선언됨 + 산출물 소실"(배포 중 dist 가 잠깐 빔)
|
|
* 이 구분되지 않아 정상 구성이 503 이 된다 — 번들 확장만 설치한 기본 구성 전부가 그랬다.
|
|
*
|
|
* 모집단은 countAssetDeclaringExtensions() 와 같다. 경로는 확장의 선언 축 게터로만 얻는다 —
|
|
* getBuiltAssetAbsolutePaths() 는 file_exists() 게이트라 부재를 셀 수 없고, base_path("modules"…)
|
|
* 직접 조립은 _bundled 확장에서 어긋난다.
|
|
*
|
|
* @param string $type 'module' | 'plugin'
|
|
* @param string $kind 'js' | 'css'
|
|
* @return list<string> 소실·판독 불가 산출물의 절대 경로 (없으면 빈 배열)
|
|
*/
|
|
public function findMissingDeclaredAssets(string $type, string $kind): array
|
|
{
|
|
try {
|
|
$extensions = $type === 'plugin'
|
|
? $this->pluginManager->getActivePlugins()
|
|
: $this->moduleManager->getActiveModules();
|
|
|
|
$missing = [];
|
|
|
|
foreach ($extensions as $extension) {
|
|
// global 전략만 번들 대상 — 병합 대상 모집단과 동일한 필터를 쓴다
|
|
if (($extension->getAssetLoadingConfig()['strategy'] ?? 'global') !== 'global') {
|
|
continue;
|
|
}
|
|
|
|
$path = $extension->getDeclaredAssetAbsolutePaths()[$kind] ?? null;
|
|
|
|
if ($path !== null && (! is_file($path) || ! is_readable($path))) {
|
|
$missing[] = $path;
|
|
}
|
|
}
|
|
|
|
return $missing;
|
|
} catch (\Throwable $e) {
|
|
// 판정 자체가 실패하면 장애로 단정하지 않는다(선언 축 카운트와 같은 fail-open). 흔적은 error 로 —
|
|
// 출하 기본 로그 수준이 error 라 warning 은 기록되지 않는다.
|
|
Log::error('확장 에셋 소실 판정 실패', [
|
|
'type' => $type,
|
|
'kind' => $kind,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 현재 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;
|
|
}
|
|
|
|
// 원자적 쓰기의 임시 파일(`{type}.{v}.{kind}.tmp.{pid}`)은 번들 파일 패턴에
|
|
// 맞지 않아 GC 대상에서 통째로 빠져 있었다 — rename 이 실패한 만큼 영구
|
|
// 잔존한다(실측 560개). 나이 가드를 붙여 진행 중인 쓰기는 건드리지 않는다.
|
|
if ($this->isStaleTempBundleFile($name, $storage)) {
|
|
if ($storage->delete('', $name)) {
|
|
$deleted++;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($this->isBundleFile($name) && $storage->delete('', $name)) {
|
|
$deleted++;
|
|
}
|
|
}
|
|
|
|
return $deleted;
|
|
}
|
|
|
|
/**
|
|
* 파일명이 **오래된** 원자적 쓰기 임시 파일인지 판정합니다.
|
|
*
|
|
* 진행 중인 쓰기를 파괴하지 않도록 나이 가드를 둔다 — pid 는 재사용되므로 "그 pid 가
|
|
* 살아 있는가" 로는 판정할 수 없다.
|
|
*
|
|
* @param string $name 파일명
|
|
* @param CoreStorageDriver $storage 번들 디스크 스토리지
|
|
* @return bool 삭제 대상 여부
|
|
*/
|
|
private function isStaleTempBundleFile(string $name, CoreStorageDriver $storage): bool
|
|
{
|
|
if (! preg_match('/^(module|plugin)\.\d+\.(js|css)\.tmp\.\d+$/', $name)) {
|
|
return false;
|
|
}
|
|
|
|
$mtime = @filemtime($storage->getBasePath('').'/'.$name);
|
|
|
|
// 나이를 읽지 못하면 남긴다 — 진행 중인 쓰기를 지우는 쪽이 더 나쁘다.
|
|
return $mtime !== false && (time() - $mtime) > self::TEMP_BUNDLE_STALE_SECONDS;
|
|
}
|
|
|
|
/**
|
|
* 번들 캐시 파일을 삭제합니다(cache-clear 커맨드용).
|
|
*
|
|
* **현재 버전은 보존한다** — `cleanupStaleBundles()` 와 같은 정책이다. 현재 버전까지
|
|
* 지우면 같은 순간 서빙 중인 웹 요청이 "존재함" 판정 직후 `filemtime()` 에서 500 을
|
|
* 낸다(bump 직후 TOCTOU). 캐시 파일은 없으면 다음 요청이 다시 만들므로, 지우는 것의
|
|
* 이득은 없고 그 창의 500 만 남는다.
|
|
*
|
|
* @param string|null $type 'module' | 'plugin' 지정 시 해당 타입만, null 이면 전체
|
|
* @return int 삭제된 파일 수
|
|
*/
|
|
public function clearBundles(?string $type = null): int
|
|
{
|
|
$storage = $this->bundleStorage();
|
|
$currentVersion = $this->getCurrentVersion();
|
|
$deleted = 0;
|
|
|
|
foreach ($storage->files('', '') as $file) {
|
|
$name = basename($file);
|
|
|
|
if ($name === '.gitignore' || ! $this->isBundleFile($name)) {
|
|
continue;
|
|
}
|
|
|
|
// 현재 버전 보존 (cleanupStaleBundles 와 동형 — 정책이 갈라지면 한쪽이 창을 연다)
|
|
if ($this->matchesVersion($name, $currentVersion)) {
|
|
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='.AssetUrl::extensionAsset(
|
|
$typeSegment,
|
|
$identifier,
|
|
'dist/js/'.basename($mapFile)
|
|
);
|
|
}, $content) ?? $content;
|
|
}
|
|
|
|
/**
|
|
* CSS 소스맵 주석을 환경에 맞게 처리합니다.
|
|
*
|
|
* CSS 는 JS 와 주석 문법이 달라 소스맵 참조를 블록 주석으로 표기하므로
|
|
* processJsSourceMap() 의 `//#` 패턴으로는 검출되지 않는다.
|
|
*
|
|
* prod: 주석 strip(맵 참조 제거). dev: 원본 유지.
|
|
* 병합 번들에서는 개별 맵 URL 이 어차피 어긋나므로 dev rewrite 는 하지 않는다.
|
|
*
|
|
* @param string $content 원본 CSS 내용
|
|
* @param bool $isProduction 프로덕션 여부
|
|
* @return string 처리된 내용
|
|
*/
|
|
private function processCssSourceMap(string $content, bool $isProduction): string
|
|
{
|
|
if (! $isProduction) {
|
|
return $content;
|
|
}
|
|
|
|
// 구분자로 `~` 사용 — 패턴에 `/`, `#` 가 포함됨
|
|
return preg_replace('~/\*#\s*sourceMappingURL=\S+?\s*\*/~', '', $content) ?? $content;
|
|
}
|
|
|
|
/**
|
|
* 번들 디스크용 스토리지 드라이버를 반환합니다(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();
|
|
}
|
|
}
|