Merge pull request from gnuboard:HeuJung/issue615

HeuJung/issue615
This commit is contained in:
정정홍
2026-08-25 19:12:11 +09:00
committed by GitHub
80 changed files with 4670 additions and 127 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ APP_ENV=production
APP_KEY= APP_KEY=
APP_DEBUG=false APP_DEBUG=false
APP_URL=http://localhost APP_URL=http://localhost
APP_VERSION=7.0.9 APP_VERSION=7.0.10
APP_LOCALE=ko APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko APP_FALLBACK_LOCALE=ko
+1 -1
View File
@@ -3,7 +3,7 @@ APP_ENV=testing
APP_KEY= APP_KEY=
APP_DEBUG=false APP_DEBUG=false
APP_URL=http://localhost APP_URL=http://localhost
APP_VERSION=7.0.9 APP_VERSION=7.0.10
APP_LOCALE=ko APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko APP_FALLBACK_LOCALE=ko
+6
View File
@@ -145,3 +145,9 @@ test-results/
# 배포용 빌드는 `--production` 이 G7_BUILD_SOURCEMAP=0 을 주입해 생성 자체를 막는다. # 배포용 빌드는 `--production` 이 G7_BUILD_SOURCEMAP=0 을 주입해 생성 자체를 막는다.
# 경로 한정 시 새 확장이 추가될 때 조용히 누락되므로 전역 규칙으로 둔다. # 경로 한정 시 새 확장이 추가될 때 조용히 누락되므로 전역 규칙으로 둔다.
*.map *.map
# ===== 부트스트랩 리소스 정적 게시 (bake, #122) =====
# 병합 산출물의 버전 디렉토리 사본 — 각 서버가 수명주기 이벤트마다 재생성하는
# 로컬 파생물이라 저장소·release 페이로드에 유입되면 안 된다.
# `public/build/core/` 는 계속 추적한다 (배포 산출물) — 혼동 금지.
/public/build/ext/
+2 -1
View File
@@ -6,7 +6,7 @@
<!-- AUTO-GENERATED-START: docs-quick-reference --> <!-- AUTO-GENERATED-START: docs-quick-reference -->
### 백엔드 [backend/](docs/backend/) (34개) ### 백엔드 [backend/](docs/backend/) (35개)
| 문서 | 설명 | TL;DR 핵심 | | 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------| |------|------|-----------|
@@ -41,6 +41,7 @@
| [service-provider.md](docs/backend/service-provider.md) | 서비스 프로바이더 안전성 | DB 접근 전 .env 파일 존재 확인 필수 | | [service-provider.md](docs/backend/service-provider.md) | 서비스 프로바이더 안전성 | DB 접근 전 .env 파일 존재 확인 필수 |
| [service-repository.md](docs/backend/service-repository.md) | Service-Repository 패턴 | RepositoryInterface 주입 필수 (구체 클래스 직접 주입 금지) | | [service-repository.md](docs/backend/service-repository.md) | Service-Repository 패턴 | RepositoryInterface 주입 필수 (구체 클래스 직접 주입 금지) |
| [settings-multilingual-enrichment.md](docs/backend/settings-multilingual-enrichment.md) | Settings 카탈로그 다국어 자동 보강 | settings JSON 의 다국어 카탈로그 라벨(_cached_name 등)은 카탈로그 빌드 시점에 보강 | | [settings-multilingual-enrichment.md](docs/backend/settings-multilingual-enrichment.md) | Settings 카탈로그 다국어 자동 보강 | settings JSON 의 다국어 카탈로그 라벨(_cached_name 등)은 카탈로그 빌드 시점에 보강 |
| [static-asset-publishing.md](docs/backend/static-asset-publishing.md) | 부트스트랩 리소스 정적 게시 (Static Asset Publishing) | 게시물: public/build/ext/{cache_version}/ — 수명주기 이벤트마다 termi... |
| [translatable-seeders.md](docs/backend/translatable-seeders.md) | 다국어 시더 인터페이스 (Translatable Seeders) | 다국어 JSON 컬럼(name 등)을 시드하는 확장 entity 시더는 TranslatableSeede... | | [translatable-seeders.md](docs/backend/translatable-seeders.md) | 다국어 시더 인터페이스 (Translatable Seeders) | 다국어 JSON 컬럼(name 등)을 시드하는 확장 entity 시더는 TranslatableSeede... |
| [user-overrides.md](docs/backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) | 모델에 `use HasUserOverrides;` + `protected array $trackable... | | [user-overrides.md](docs/backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) | 모델에 `use HasUserOverrides;` + `protected array $trackable... |
| [validation.md](docs/backend/validation.md) | 검증 (Validation) | 필수: FormRequest에서 검증 (Service에 검증 로직 배치 금지) | | [validation.md](docs/backend/validation.md) | 검증 (Validation) | 필수: FormRequest에서 검증 (Service에 검증 로직 배치 금지) |
+16
View File
@@ -4,6 +4,22 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [7.0.10] - 2026-08-25
### Added
- 초기 화면에 필요한 다국어·컴포넌트 정의·라우트 정보·확장 번들·템플릿 에셋을 정적 파일로 미리 만들어 웹서버가 직접 전달합니다. 확장 설치/활성화나 레이아웃 편집 시 자동으로 다시 생성되며, 파일이 없으면 기존 방식으로 동작합니다. 초기 화면 표시가 빨라집니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
### Changed
- 템플릿 컴포넌트 정의·다국어·라우트 응답에 조건부 캐시(ETag)가 적용되어, 변경이 없으면 본문 전송 없이 캐시를 재사용합니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
### Fixed
- 사이트 첫 접속 시 확장 캐시 버전이 어긋나 있으면 라우트·다국어 데이터를 두 번 내려받던 문제를 수정했습니다. (#122 @glitter-gim 님께서 건의해주셨습니다.)
- 레이아웃 props 의 `$switch` 조건 분기 값이 검색엔진(봇) 화면에서는 해석되지 않아 해당 속성이 표시되지 않던 문제를 수정했습니다. 이제 일반 화면과 봇 화면이 동일하게 분기 값을 렌더링합니다.
- sudo(root) 로 코어를 업데이트하면 업데이트 과정이 만든 캐시 파일이 root 소유로 남아, 이후 웹 화면 전체가 서버 오류(500)가 되거나 캐시가 동작하지 않을 수 있던 문제를 수정했습니다. 업데이트 종료 시 캐시·번들 디렉토리 소유권을 자동 정상화하고, 캐시 쓰기 실패는 화면을 중단시키지 않고 경고 로그와 함께 무캐시로 계속 동작합니다.
## [7.0.9] - 2026-08-24 ## [7.0.9] - 2026-08-24
### Added ### Added
+1 -1
View File
@@ -289,7 +289,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경 # 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la ls -la
# (필요 시) mv g7-7.0.9 g7 # (필요 시) mv g7-7.0.10 g7
# ZIP 파일 정리 (선택) # ZIP 파일 정리 (선택)
rm g7-release.zip rm g7-release.zip
+1 -1
View File
@@ -10,7 +10,7 @@
</p> </p>
<p align="center"> <p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.0.9-blue" alt="Version"></a> <a href="#"><img src="https://img.shields.io/badge/version-7.0.10-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a> <a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a> <a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a> <a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
+1 -1
View File
@@ -10,7 +10,7 @@
</p> </p>
<p align="center"> <p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.0.9-blue" alt="Version"></a> <a href="#"><img src="https://img.shields.io/badge/version-7.0.10-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a> <a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a> <a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a> <a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
@@ -0,0 +1,42 @@
<?php
namespace App\Console\Commands;
use App\Services\ExtensionStaticCacheService;
use Illuminate\Console\Command;
/**
* 오래된 부트스트랩 리소스 정적 게시 디렉토리를 정리하는 커맨드
*
* 게시 디렉토리(`public/build/ext/{version}/`)는 캐시 스토어 밖 파일시스템이라
* version bump 가 구버전 디렉토리를 지우지 않는다. 게시 성공 직후 인라인 GC 가
* 돌지만, 게시가 오래 없거나 실패한 환경의 잔존물을 이 커맨드가 회수한다.
* (`CleanupExtensionBundlesCommand` 파일 산출물 GC 패턴 미러, #122)
*/
class CleanupExtensionStaticCacheCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'ext-static:cleanup';
/**
* The console command description.
*/
protected $description = '오래된 부트스트랩 리소스 정적 게시 디렉토리(구 version)를 삭제합니다';
/**
* Execute the console command.
*
* @param ExtensionStaticCacheService $service 정적 게시 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(ExtensionStaticCacheService $service): int
{
$deleted = $service->cleanup();
$this->info("오래된 정적 게시 디렉토리 {$deleted}건이 삭제되었습니다.");
return self::SUCCESS;
}
}
@@ -593,6 +593,10 @@ class CoreUpdateCommand extends Command
// fallback(spawn 실패)로 부모가 upgrade step 을 직접 실행한 경우, 부모가 만든 // fallback(spawn 실패)로 부모가 upgrade step 을 직접 실행한 경우, 부모가 만든
// upgrade 로그가 root 로 남는다 — 모든 로그 쓰기가 끝난 이 시점에 정합한다. // upgrade 로그가 root 로 남는다 — 모든 로그 쓰기가 끝난 이 시점에 정합한다.
$this->restoreUpgradeLogOwnership(); $this->restoreUpgradeLogOwnership();
// root 업데이트가 종료 시점까지 만든 캐시/번들 산출물 소유권 정상화 —
// restoreOwnership(흐름 중간) 이후의 root 쓰기가 웹 캐시 쓰기를 죽이는
// 전면 500 차단 (7.0.9→7.0.10 실사례)
app(CoreUpdateService::class)->normalizeRuntimeOwnershipAfterRootRun();
return Command::SUCCESS; return Command::SUCCESS;
@@ -677,6 +681,10 @@ class CoreUpdateCommand extends Command
} }
$this->restoreUpgradeLogOwnership(); $this->restoreUpgradeLogOwnership();
// root 업데이트가 종료 시점까지 만든 캐시/번들 산출물 소유권 정상화 —
// restoreOwnership(흐름 중간) 이후의 root 쓰기가 웹 캐시 쓰기를 죽이는
// 전면 500 차단 (7.0.9→7.0.10 실사례)
app(CoreUpdateService::class)->normalizeRuntimeOwnershipAfterRootRun();
return Command::SUCCESS; return Command::SUCCESS;
@@ -772,6 +780,10 @@ class CoreUpdateCommand extends Command
} }
$this->restoreUpgradeLogOwnership(); $this->restoreUpgradeLogOwnership();
// root 업데이트가 종료 시점까지 만든 캐시/번들 산출물 소유권 정상화 —
// restoreOwnership(흐름 중간) 이후의 root 쓰기가 웹 캐시 쓰기를 죽이는
// 전면 500 차단 (7.0.9→7.0.10 실사례)
app(CoreUpdateService::class)->normalizeRuntimeOwnershipAfterRootRun();
return Command::FAILURE; return Command::FAILURE;
} }
@@ -206,6 +206,7 @@ class ExecuteUpgradeStepsCommand extends Command
]); ]);
$this->restoreUpgradeLogOwnership(); $this->restoreUpgradeLogOwnership();
$service->normalizeRuntimeOwnershipAfterRootRun();
return UpgradeHandoffException::EXIT_CODE; return UpgradeHandoffException::EXIT_CODE;
} catch (\Throwable $e) { } catch (\Throwable $e) {
@@ -217,6 +218,7 @@ class ExecuteUpgradeStepsCommand extends Command
$this->error($e->getMessage()); $this->error($e->getMessage());
$this->restoreUpgradeLogOwnership(); $this->restoreUpgradeLogOwnership();
$service->normalizeRuntimeOwnershipAfterRootRun();
return self::FAILURE; return self::FAILURE;
} }
@@ -278,6 +280,10 @@ class ExecuteUpgradeStepsCommand extends Command
} }
$this->restoreUpgradeLogOwnership(); $this->restoreUpgradeLogOwnership();
// 단독 실행(sudo core:execute-upgrade-steps)이 만든 캐시/번들 root 산출물
// 소유권 정상화 — spawn 자식 모드에서도 무해(멱등)하며, 부모(CoreUpdateCommand)
// 종료부의 동일 호출이 부모 측 후속 쓰기를 담당한다
$service->normalizeRuntimeOwnershipAfterRootRun();
return self::SUCCESS; return self::SUCCESS;
} }
@@ -0,0 +1,53 @@
<?php
namespace App\Console\Commands;
use App\Services\ExtensionStaticCacheService;
use Illuminate\Console\Command;
/**
* 부트스트랩 리소스 정적 게시(bake)를 수동 수행하는 커맨드
*
* 수명주기 이벤트(terminating 트리거)와 blade 자가 치유가 정상 경로지만,
* 배포 직후 워밍이나 수동 복구가 필요할 때 이 커맨드로 즉시 게시한다.
* 설치기 완료 단계에서도 호출된다 (#122).
*/
class PublishExtensionStaticCacheCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'ext-static:publish {--force : 게시 완료 상태여도 강제 재게시}';
/**
* The console command description.
*/
protected $description = '부트스트랩 리소스(다국어·컴포넌트·라우트·번들·템플릿 에셋)를 정적 파일로 게시합니다';
/**
* Execute the console command.
*
* @param ExtensionStaticCacheService $service 정적 게시 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(ExtensionStaticCacheService $service): int
{
if (! $service->isEnabled()) {
$this->warn('정적 게시가 비활성화되어 있습니다 (G7_STATIC_CACHE=false). 게시를 건너뜁니다.');
return self::SUCCESS;
}
$published = $service->publishCurrent(force: (bool) $this->option('force'));
if (! $published) {
$this->error('정적 게시에 실패했습니다. 로그를 확인하세요 — 사이트는 API 폴백으로 정상 동작합니다.');
return self::FAILURE;
}
$this->info('부트스트랩 리소스 정적 게시가 완료되었습니다.');
return self::SUCCESS;
}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Exceptions;
/**
* 부트스트랩 리소스 정적 게시(bake) 실패 예외.
*
* 사용자 대면 예외가 아니다 — `ExtensionStaticCacheService::publishVersion()` 의
* 자체 catch 가 즉시 삼켜 Log::warning 진단으로만 남기고, 사이트는 API 폴백으로
* 정상 동작한다. 따라서 메시지는 다국어 키가 아니라 운영자 로그용 진단 문자열이다.
*/
class StaticCachePublishException extends \RuntimeException {}
+113 -29
View File
@@ -3,6 +3,7 @@
namespace App\Extension\Cache; namespace App\Extension\Cache;
use App\Contracts\Extension\CacheInterface; use App\Contracts\Extension\CacheInterface;
use Illuminate\Cache\Repository;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -48,15 +49,15 @@ abstract class AbstractCacheDriver implements CacheInterface
*/ */
public function resolveKey(string $key): string public function resolveKey(string $key): string
{ {
return $this->getPrefix() . ':' . $key; return $this->getPrefix().':'.$key;
} }
/** /**
* Laravel Cache 스토어 인스턴스를 반환합니다. * Laravel Cache 스토어 인스턴스를 반환합니다.
* *
* @return \Illuminate\Cache\Repository 캐시 스토어 인스턴스 * @return Repository 캐시 스토어 인스턴스
*/ */
protected function store(): \Illuminate\Cache\Repository protected function store(): Repository
{ {
return Cache::store($this->store); return Cache::store($this->store);
} }
@@ -122,7 +123,13 @@ abstract class AbstractCacheDriver implements CacheInterface
$resolvedKey = $this->resolveKey($key); $resolvedKey = $this->resolveKey($key);
$ttl = $ttl ?? $this->getDefaultTtl(); $ttl = $ttl ?? $this->getDefaultTtl();
return $this->store()->put($resolvedKey, $value, $ttl); try {
return $this->store()->put($resolvedKey, $value, $ttl);
} catch (\Throwable $e) {
$this->warnWriteFailure('put', $resolvedKey, $e);
return false;
}
} }
/** /**
@@ -144,7 +151,13 @@ abstract class AbstractCacheDriver implements CacheInterface
*/ */
public function forget(string $key): bool public function forget(string $key): bool
{ {
return $this->store()->forget($this->resolveKey($key)); try {
return $this->store()->forget($this->resolveKey($key));
} catch (\Throwable $e) {
$this->warnWriteFailure('forget', $this->resolveKey($key), $e);
return false;
}
} }
// === Remember 패턴 === // === Remember 패턴 ===
@@ -170,8 +183,25 @@ abstract class AbstractCacheDriver implements CacheInterface
// 항상 일반 remember + 키 인덱스에 태그 매핑 기록 // 항상 일반 remember + 키 인덱스에 태그 매핑 기록
// Laravel 네이티브 태그 저장은 사용하지 않음 (get/has와의 일관성 보장) // Laravel 네이티브 태그 저장은 사용하지 않음 (get/has와의 일관성 보장)
$result = $this->store()->remember($resolvedKey, $ttl, $callback); //
$this->recordKeyTags($resolvedKey, $allTags); // 저장 실패는 fail-soft — 캐시는 최적화이므로 콜백 결과를 그대로 반환한다.
// Repository::remember 를 쓰지 않고 get→callback→put 을 직접 수행하는 이유:
// put 예외를 잡아도 콜백을 재실행하지 않기 위해서다 (부수효과 이중 실행 금지).
// 실사례: sudo 업데이트가 키 인덱스 파일을 root 소유로 남기면 웹의 모든
// remember 가 put 예외로 죽어 부팅 전면 500 이 됐다 (7.0.9→7.0.10).
$cached = $this->store()->get($resolvedKey);
if ($cached !== null) {
return $cached;
}
$result = $callback();
try {
$this->store()->put($resolvedKey, $result, $ttl);
$this->recordKeyTags($resolvedKey, $allTags);
} catch (\Throwable $e) {
$this->warnWriteFailure('remember', $resolvedKey, $e);
}
return $result; return $result;
} }
@@ -187,7 +217,7 @@ abstract class AbstractCacheDriver implements CacheInterface
*/ */
public function rememberQuery(string $queryHash, callable $callback, ?int $ttl = null, array $tags = []): mixed public function rememberQuery(string $queryHash, callable $callback, ?int $ttl = null, array $tags = []): mixed
{ {
return $this->remember('query:' . $queryHash, $callback, $ttl, $tags); return $this->remember('query:'.$queryHash, $callback, $ttl, $tags);
} }
// === 벌크 연산 === // === 벌크 연산 ===
@@ -237,7 +267,13 @@ abstract class AbstractCacheDriver implements CacheInterface
$resolved[$this->resolveKey($key)] = $value; $resolved[$this->resolveKey($key)] = $value;
} }
return $this->store()->putMany($resolved, $ttl); try {
return $this->store()->putMany($resolved, $ttl);
} catch (\Throwable $e) {
$this->warnWriteFailure('putMany', implode(',', array_keys($resolved)), $e);
return false;
}
} }
// === 무효화 === // === 무효화 ===
@@ -291,6 +327,42 @@ abstract class AbstractCacheDriver implements CacheInterface
// === 키 인덱스 (태그 미지원 드라이버용) === // === 키 인덱스 (태그 미지원 드라이버용) ===
/**
* 캐시 쓰기 실패를 경고 로그로 강등합니다 (fail-soft).
*
* 캐시는 최적화다 — 쓰기 실패(권한/디스크)가 페이지를 죽이면 안 된다. 실사례:
* sudo 코어 업데이트가 키 인덱스 파일을 root 소유로 남겨 웹 프로세스의 모든
* 캐시 쓰기가 Permission denied 로 죽고 부팅 경로가 전면 500 이 됐다
* (예외가 로거 도달 전에 발생해 laravel.log 도 비어 있었다).
*
* 로그 폭주 방지: 같은 (연산, 예외 메시지) 조합은 프로세스당 1회만 기록한다.
*
* @param string $operation 실패한 연산 (put/forget/putMany/remember/index)
* @param string $key 대상 키 (진단용)
* @param \Throwable $e 원인 예외
*/
protected function warnWriteFailure(string $operation, string $key, \Throwable $e): void
{
static $warned = [];
$signature = $operation.'|'.$e->getMessage();
if (isset($warned[$signature])) {
return;
}
$warned[$signature] = true;
try {
Log::warning('캐시 쓰기 실패 — 무캐시로 계속 동작합니다 (스토리지 권한/디스크 확인 필요)', [
'operation' => $operation,
'key' => $key,
'store' => $this->store,
'error' => $e->getMessage(),
]);
} catch (\Throwable) {
// 로그 기록조차 불가한 환경(로그 디렉토리 권한 등) — 조용히 계속
}
}
/** /**
* 키-태그 매핑을 인덱스에 기록합니다. * 키-태그 매핑을 인덱스에 기록합니다.
* *
@@ -318,16 +390,22 @@ abstract class AbstractCacheDriver implements CacheInterface
*/ */
private function flushByIndex(): bool private function flushByIndex(): bool
{ {
$indexKey = $this->getIndexKey(); try {
$index = $this->store()->get($indexKey, []); $indexKey = $this->getIndexKey();
$index = $this->store()->get($indexKey, []);
foreach (array_keys($index) as $key) { foreach (array_keys($index) as $key) {
$this->store()->forget($key); $this->store()->forget($key);
}
$this->store()->forget($indexKey);
return true;
} catch (\Throwable $e) {
$this->warnWriteFailure('flush', $this->getIndexKey(), $e);
return false;
} }
$this->store()->forget($indexKey);
return true;
} }
/** /**
@@ -338,20 +416,26 @@ abstract class AbstractCacheDriver implements CacheInterface
*/ */
private function flushTagsByIndex(array $tags): bool private function flushTagsByIndex(array $tags): bool
{ {
$indexKey = $this->getIndexKey(); try {
$index = $this->store()->get($indexKey, []); $indexKey = $this->getIndexKey();
$tagsSet = array_flip($tags); $index = $this->store()->get($indexKey, []);
$tagsSet = array_flip($tags);
foreach ($index as $key => $keyTags) { foreach ($index as $key => $keyTags) {
if (array_intersect_key(array_flip($keyTags), $tagsSet)) { if (array_intersect_key(array_flip($keyTags), $tagsSet)) {
$this->store()->forget($key); $this->store()->forget($key);
unset($index[$key]); unset($index[$key]);
}
} }
$this->store()->put($indexKey, $index, 86400 * 30);
return true;
} catch (\Throwable $e) {
$this->warnWriteFailure('flushTags', implode(',', $tags), $e);
return false;
} }
$this->store()->put($indexKey, $index, 86400 * 30);
return true;
} }
/** /**
@@ -361,6 +445,6 @@ abstract class AbstractCacheDriver implements CacheInterface
*/ */
private function getIndexKey(): string private function getIndexKey(): string
{ {
return 'g7:_idx:' . $this->getPrefix(); return 'g7:_idx:'.$this->getPrefix();
} }
} }
@@ -4,6 +4,7 @@ namespace App\Extension\Traits;
use App\Contracts\Extension\CacheInterface; use App\Contracts\Extension\CacheInterface;
use App\Extension\Cache\CoreCacheDriver; use App\Extension\Cache\CoreCacheDriver;
use App\Services\ExtensionStaticCacheService;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
/** /**
@@ -48,6 +49,12 @@ trait ClearsTemplateCaches
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
} }
// 부트스트랩 리소스 정적 게시(bake) 예약 — 모든 bump 호출부(수명주기 전체)가
// 이 단일 지점을 경유하므로 재게시 트리거 누락이 구조적으로 불가능하다 (#122).
// terminating 시점에 프로세스당 1회, 실행 시점의 최종 버전으로 게시된다
// (연속 bump 자연 병합). 실패해도 사이트는 API 폴백으로 정상.
ExtensionStaticCacheService::schedulePublishOnTerminate();
} }
/** /**
@@ -210,17 +210,37 @@ abstract class BaseApiController extends Controller
} }
/** /**
* JSON 응답을 반환합니다 (캐싱 헤더 포함). * JSON 응답을 반환합니다 (조건부 캐싱 헤더 포함).
*
* ETag 기반 조건부 캐시(If-None-Match → 304)와 환경별 Cache-Control 분기를
* 적용한다 — 프로덕션은 `public, max-age`, 그 외 환경은 `no-cache`(파일 수정
* 즉시 반영 — `fileResponse` 의 환경 분기와 동일 사상).
* *
* @param mixed $data JSON으로 변환할 데이터 * @param mixed $data JSON으로 변환할 데이터
* @param int $maxAge 캐시 유지 시간 (초, 기본: 1시간) * @param int $maxAge 캐시 유지 시간 (초, 기본: 1시간)
* @param int $status HTTP 상태 코드 * @param int $status HTTP 상태 코드
* @return JsonResponse JSON 응답 * @return JsonResponse|Response JSON 응답 또는 304 응답
*/ */
protected function cachedJsonResponse(mixed $data, int $maxAge = 3600, int $status = 200): JsonResponse protected function cachedJsonResponse(mixed $data, int $maxAge = 3600, int $status = 200): JsonResponse|Response
{ {
$etag = $this->generateETag($data);
$cacheControl = app()->environment('production')
? "public, max-age={$maxAge}"
: 'no-cache';
if ($status === 200 && $this->isNotModified($etag)) {
$response = $this->notModifiedResponse($etag, $maxAge);
$response->headers->set('Cache-Control', $cacheControl);
$response->headers->set('Vary', 'Accept-Encoding');
return $response;
}
return response()->json($data, $status, [ return response()->json($data, $status, [
'Cache-Control' => "public, max-age={$maxAge}", 'Cache-Control' => $cacheControl,
'ETag' => $etag,
'Vary' => 'Accept-Encoding',
], ResponseHelper::JSON_ENCODE_OPTIONS); ], ResponseHelper::JSON_ENCODE_OPTIONS);
} }
@@ -283,9 +303,18 @@ abstract class BaseApiController extends Controller
): JsonResponse|Response { ): JsonResponse|Response {
$etag = $this->generateETag($data); $etag = $this->generateETag($data);
// 환경별 캐싱 정책 — 프로덕션 외 환경은 no-cache 로 파일/데이터 수정 즉시 반영
// (`fileResponse`/`cachedJsonResponse` 와 동일 사상, #122 작업 D)
$cacheControl = app()->environment('production')
? "public, max-age={$maxAge}"
: 'no-cache';
// 304 Not Modified 처리 // 304 Not Modified 처리
if ($this->isNotModified($etag)) { if ($this->isNotModified($etag)) {
return $this->notModifiedResponse($etag, $maxAge); $response = $this->notModifiedResponse($etag, $maxAge);
$response->headers->set('Cache-Control', $cacheControl);
return $response;
} }
return response()->json([ return response()->json([
@@ -294,7 +323,7 @@ abstract class BaseApiController extends Controller
'data' => $data, 'data' => $data,
], 200, [], ResponseHelper::JSON_ENCODE_OPTIONS) ], 200, [], ResponseHelper::JSON_ENCODE_OPTIONS)
->header('ETag', $etag) ->header('ETag', $etag)
->header('Cache-Control', "public, max-age={$maxAge}") ->header('Cache-Control', $cacheControl)
->header('Vary', 'Accept-Encoding, Accept-Language'); ->header('Vary', 'Accept-Encoding, Accept-Language');
} }
} }
@@ -140,9 +140,9 @@ class PublicModuleController extends PublicBaseController
* 폴백한다(무손실 보존 디그레이드). * 폴백한다(무손실 보존 디그레이드).
* *
* @param string $identifier 모듈 식별자 * @param string $identifier 모듈 식별자
* @return JsonResponse 컴포넌트 정의 응답 * @return JsonResponse|Response 컴포넌트 정의 응답 (If-None-Match 일치 시 304)
*/ */
public function serveComponents(string $identifier): JsonResponse public function serveComponents(string $identifier): JsonResponse|Response
{ {
$this->logApiUsage('modules.components', ['identifier' => $identifier]); $this->logApiUsage('modules.components', ['identifier' => $identifier]);
@@ -139,9 +139,9 @@ class PublicPluginController extends PublicBaseController
* 폴백한다(무손실 보존 디그레이드). * 폴백한다(무손실 보존 디그레이드).
* *
* @param string $identifier 플러그인 식별자 * @param string $identifier 플러그인 식별자
* @return JsonResponse 컴포넌트 정의 응답 * @return JsonResponse|Response 컴포넌트 정의 응답 (If-None-Match 일치 시 304)
*/ */
public function serveComponents(string $identifier): JsonResponse public function serveComponents(string $identifier): JsonResponse|Response
{ {
$this->logApiUsage('plugins.components', ['identifier' => $identifier]); $this->logApiUsage('plugins.components', ['identifier' => $identifier]);
@@ -35,9 +35,9 @@ class PublicTemplateController extends PublicBaseController
* 템플릿 라우트 정보 조회 (활성화된 모듈의 routes 포함) * 템플릿 라우트 정보 조회 (활성화된 모듈의 routes 포함)
* *
* @param string $identifier 템플릿 식별자 (vendor-name 형식) * @param string $identifier 템플릿 식별자 (vendor-name 형식)
* @return JsonResponse 라우트 정보 응답 * @return JsonResponse|Response 라우트 정보 응답 (`?v` 명시 + If-None-Match 일치 시 304)
*/ */
public function getRoutes(string $identifier): JsonResponse public function getRoutes(string $identifier): JsonResponse|Response
{ {
// API 사용량 기록 // API 사용량 기록
$this->logApiUsage('templates.routes', ['identifier' => $identifier]); $this->logApiUsage('templates.routes', ['identifier' => $identifier]);
@@ -101,6 +101,15 @@ class PublicTemplateController extends PublicBaseController
return $this->error(__('templates.errors.invalid_cache_data'), 500); return $this->error(__('templates.errors.invalid_cache_data'), 500);
} }
// 버전 키드 URL(`?v` 명시)은 bump 시 URL 자체가 바뀌므로 조건부 공개 캐시가 안전하다.
// 무버전 요청(핸드셰이크 폴백 등)은 종전대로 캐시 헤더 없이 신선 응답 (#122 작업 D).
// 열화 스냅샷은 공개 캐시 금지 — 서버측 캐시 회피(#493)와 동일 규율로, 같은 `?v`
// URL 에 public max-age 가 붙으면 브라우저/CDN 이 열화 응답을 1시간 박제한다
// (버전은 이미 올라간 뒤라 스스로 회복되지 않음. 정적 게시의 열화 제외와 대칭).
if ($rawVersion !== null && ! $this->templateService->lastRouteMergeWasDegraded()) {
return $this->successWithCache('templates.messages.routes_retrieved', $routesData['data'], 3600);
}
return $this->success( return $this->success(
__('templates.messages.routes_retrieved'), __('templates.messages.routes_retrieved'),
$routesData['data'] $routesData['data']
@@ -145,9 +154,9 @@ class PublicTemplateController extends PublicBaseController
* 컴포넌트 정의 파일 서빙 * 컴포넌트 정의 파일 서빙
* *
* @param string $identifier 템플릿 식별자 * @param string $identifier 템플릿 식별자
* @return JsonResponse 컴포넌트 정의 응답 * @return JsonResponse|Response 컴포넌트 정의 응답 (If-None-Match 일치 시 304)
*/ */
public function serveComponents(string $identifier): JsonResponse public function serveComponents(string $identifier): JsonResponse|Response
{ {
// API 사용량 기록 // API 사용량 기록
$this->logApiUsage('templates.components', ['identifier' => $identifier]); $this->logApiUsage('templates.components', ['identifier' => $identifier]);
@@ -244,9 +253,9 @@ class PublicTemplateController extends PublicBaseController
* *
* @param string $identifier 템플릿 식별자 * @param string $identifier 템플릿 식별자
* @param string $locale 로케일 (ko, en 등) * @param string $locale 로케일 (ko, en 등)
* @return JsonResponse 다국어 데이터 응답 * @return JsonResponse|Response 다국어 데이터 응답 (If-None-Match 일치 시 304)
*/ */
public function serveLanguage(string $identifier, string $locale): JsonResponse public function serveLanguage(string $identifier, string $locale): JsonResponse|Response
{ {
// API 사용량 기록 // API 사용량 기록
$this->logApiUsage('templates.language', [ $this->logApiUsage('templates.language', [
+76
View File
@@ -1457,6 +1457,9 @@ class ComponentHtmlMapper
// 표현식 해석: 문자열/숫자는 evaluate, 배열/객체는 evaluateRaw // 표현식 해석: 문자열/숫자는 evaluate, 배열/객체는 evaluateRaw
$resolved = $evaluator->evaluateRaw($value, $context); $resolved = $evaluator->evaluateRaw($value, $context);
$data[$key] = $resolved; $data[$key] = $resolved;
} elseif ($this->isSwitchDefinition($value)) {
// $switch 선언적 분기 (React resolveObject 와 동일 위치에서 해석)
$data[$key] = $this->resolveSwitchValue($value, $context, $evaluator);
} elseif (is_array($value)) { } elseif (is_array($value)) {
// 중첩 객체 (예: socialLinks): 재귀적으로 해석 // 중첩 객체 (예: socialLinks): 재귀적으로 해석
$data[$key] = $this->resolveAllPropsRecursive($value, $context, $evaluator); $data[$key] = $this->resolveAllPropsRecursive($value, $context, $evaluator);
@@ -1482,6 +1485,8 @@ class ComponentHtmlMapper
foreach ($values as $k => $v) { foreach ($values as $k => $v) {
if (is_string($v) && str_contains($v, '{{')) { if (is_string($v) && str_contains($v, '{{')) {
$result[$k] = $evaluator->evaluateRaw($v, $context); $result[$k] = $evaluator->evaluateRaw($v, $context);
} elseif ($this->isSwitchDefinition($v)) {
$result[$k] = $this->resolveSwitchValue($v, $context, $evaluator);
} elseif (is_array($v)) { } elseif (is_array($v)) {
$result[$k] = $this->resolveAllPropsRecursive($v, $context, $evaluator); $result[$k] = $this->resolveAllPropsRecursive($v, $context, $evaluator);
} else { } else {
@@ -1492,6 +1497,71 @@ class ComponentHtmlMapper
return $result; return $result;
} }
/**
* 값이 `$switch` 선언적 분기 객체인지 판정합니다.
*
* React `DataBindingEngine.isSwitchExpression` 과 동일 — `$switch` 와 `$cases`
* 키를 모두 가진 객체(연관 배열)만 해당한다.
*
* @param mixed $value 판정 대상 값
* @return bool $switch 정의 여부
*/
private function isSwitchDefinition(mixed $value): bool
{
return is_array($value)
&& array_key_exists('$switch', $value)
&& array_key_exists('$cases', $value);
}
/**
* `$switch` 선언적 분기 객체를 해석합니다.
*
* React `DataBindingEngine.resolveSwitch` 와 동일 의미론 (engine-v1.56.0 패리티):
* ① `$switch` 키 표현식을 평가해 문자열 키로 정규화(trim, 실패 시 빈 문자열)
* ② `$cases` 에서 키 일치 값 선택, 없으면 `$default`, 그것도 없으면 null(React undefined)
* ③ 결과가 `{{}}` 포함 문자열이면 재해석, 객체면 재귀 해석(중첩 $switch 포함)
*
* 레이아웃 최상위 `computed` 의 $switch 는 `SeoRenderer::resolveComputedSwitch` 가
* 별도 처리한다 — 이 메서드는 노드 props 값 축 담당.
*
* @param array $definition $switch 정의 { "$switch", "$cases", "$default"? }
* @param array $context 데이터 컨텍스트
* @param ExpressionEvaluator $evaluator 표현식 평가기
* @return mixed 해석된 값 (매칭·기본값 모두 없으면 null)
*/
private function resolveSwitchValue(array $definition, array $context, ExpressionEvaluator $evaluator): mixed
{
try {
$keyValue = trim((string) $evaluator->evaluate((string) ($definition['$switch'] ?? ''), $context));
} catch (\Throwable) {
$keyValue = '';
}
$cases = is_array($definition['$cases'] ?? null) ? $definition['$cases'] : [];
if ($keyValue !== '' && array_key_exists($keyValue, $cases)) {
$result = $cases[$keyValue];
} elseif (array_key_exists('$default', $definition)) {
$result = $definition['$default'];
} else {
return null;
}
if (is_string($result)) {
return str_contains($result, '{{') ? $evaluator->evaluate($result, $context) : $result;
}
if ($this->isSwitchDefinition($result)) {
return $this->resolveSwitchValue($result, $context, $evaluator);
}
if (is_array($result)) {
return $this->resolveAllPropsRecursive($result, $context, $evaluator);
}
return $result;
}
/** /**
* {field|alt_field} 패턴에서 아이템 값을 해석합니다. * {field|alt_field} 패턴에서 아이템 값을 해석합니다.
* *
@@ -1688,6 +1758,12 @@ class ComponentHtmlMapper
continue; continue;
} }
// $switch 선언적 분기 객체 (engine-v1.56.0 React 패리티) — 해석하지 않으면
// 배열이라는 이유로 속성이 조용히 사라진다 (예외·경고 없음)
if ($this->isSwitchDefinition($value)) {
$value = $this->resolveSwitchValue($value, $context, $evaluator);
}
if (is_string($value)) { if (is_string($value)) {
$evaluated = $evaluator->evaluate($value, $context); $evaluated = $evaluator->evaluate($value, $context);
if ($evaluated !== '') { if ($evaluated !== '') {
+11 -1
View File
@@ -730,7 +730,12 @@ class SeoRenderer implements SeoRendererInterface
foreach ($cssPaths as $cssPath) { foreach ($cssPaths as $cssPath) {
// dist/ 접두사 제거 (서빙 경로에서는 dist가 자동 추가됨) // dist/ 접두사 제거 (서빙 경로에서는 dist가 자동 추가됨)
$servePath = preg_replace('#^dist/#', '', $cssPath); $servePath = preg_replace('#^dist/#', '', $cssPath);
$urls[] = AssetUrl::templateAsset($templateIdentifier, $servePath);
// 정적 게시본(bake) 경로 금지 — 이 URL 은 SeoCacheManager(`seo.page.*`,
// 키에 cache_version 미포함)에 캐시된 HTML 에 박제되는데, 정적 디렉토리는
// GC 가 현재+직전 1개만 보존해 캐시 수명 안에 404 가 될 수 있다. SEO HTML 은
// asset-url-recovery 파샬도 없어 자가 복구가 불가하므로 무버전 API URL 고정.
$urls[] = AssetUrl::templateAsset($templateIdentifier, $servePath, allowStatic: false);
} }
return $urls; return $urls;
@@ -954,6 +959,11 @@ class SeoRenderer implements SeoRendererInterface
* 프론트엔드 TemplateApp이 레이아웃 레벨 initLocal/initGlobal을 상태에 적용하는 것과 * 프론트엔드 TemplateApp이 레이아웃 레벨 initLocal/initGlobal을 상태에 적용하는 것과
* 동일하게, 각 값의 {{}} 표현식을 해석해 반환합니다. * 동일하게, 각 값의 {{}} 표현식을 해석해 반환합니다.
* *
* **데이터소스 레벨 `initLocal` 옵션은 의도적으로 처리하지 않는다** (2026-08-25 확정) —
* 그 옵션을 쓰는 화면(장바구니·주문서·프로필 수정·게시판 작성 폼 등)은 인증·인터랙션
* 화면이라 봇 렌더 가치가 없다. 봇 노출이 필요한 상태 시드는 레이아웃 최상위
* `initLocal`/`state` 를 사용한다 (docs/backend/seo-system.md 지원 노드 키 표 참조).
*
* @param mixed $block 초기 상태 블록 (키 → 값) * @param mixed $block 초기 상태 블록 (키 → 값)
* @param array $context 현재 컨텍스트 (route, query 등 포함) * @param array $context 현재 컨텍스트 (route, query 등 포함)
* @return array 평가된 초기 상태 * @return array 평가된 초기 상태
+50
View File
@@ -2188,6 +2188,56 @@ class CoreUpdateService
} }
} }
/**
* root 로 실행된 업데이트가 종료된 뒤, 런타임 쓰기 디렉토리의 소유권을 정상화합니다.
*
* `restoreOwnership()` 은 흐름 **중간**의 한 단계라, 그 이후에 일어나는 캐시
* 쓰기(버전 bump·상태/훅 캐시 재생성·키 인덱스 갱신·번들 빌드)가 root 소유
* 파일을 새로 만든다. 그 파일들이 남으면 웹 프로세스의 캐시 쓰기가 Permission
* denied 로 죽어 전면 500 이 된다 (실사례: 7.0.9→7.0.10 sudo 업데이트 —
* 치명점은 모든 remember 가 갱신하는 캐시 키 인덱스 파일).
*
* 따라서 이 메서드는 **흐름의 마지막**(restoreUpgradeLogOwnership 과 같은
* 지점)에서 호출되어, 대상 디렉토리 자신의 소유자(웹 쓰기 소유)를 기준으로
* 내용물을 재귀 정상화하고 그룹 쓰기를 동기화한다. 과거 업데이트가 남긴
* root 잔재도 함께 정리된다 (재귀 전체 대상).
*
* 비-root 프로세스는 chown 자체가 불가능하고 필요도 없으므로 즉시 no-op.
* 기준 디렉토리 자체가 root 소유(비정상 배포)면 상속 근거가 없어 스킵한다.
*/
public function normalizeRuntimeOwnershipAfterRootRun(): void
{
if (! function_exists('chown') || ! function_exists('posix_geteuid') || posix_geteuid() !== 0) {
return;
}
$targets = [
storage_path('framework/cache'),
base_path('bootstrap/cache'),
storage_path('app/ext-bundles'),
];
foreach ($targets as $dir) {
if (! is_dir($dir)) {
continue;
}
$owner = @fileowner($dir);
$group = @filegroup($dir);
if ($owner === false || $owner === 0) {
Log::channel('upgrade')->warning('런타임 소유권 정상화 스킵 — 기준 디렉토리가 root/판독불가 소유', [
'dir' => $dir,
]);
continue;
}
FilePermissionHelper::chownRecursive($dir, $owner, $group);
FilePermissionHelper::syncGroupWritability($dir);
}
}
/** /**
* 업데이트 경로의 소유권을 스냅샷 기준으로 복원합니다. * 업데이트 경로의 소유권을 스냅샷 기준으로 복원합니다.
* *
@@ -0,0 +1,645 @@
<?php
namespace App\Services;
use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Exceptions\StaticCachePublishException;
use App\Extension\Helpers\FilePermissionHelper;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Helpers\ResponseHelper;
use App\Models\Template;
use App\Rules\AllowedTemplateFileType;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
/**
* 부트스트랩 리소스 정적 게시(bake) 서비스.
*
* 병합 결과물(다국어·컴포넌트 정의·라우트·확장 번들·템플릿 dist 에셋)을
* 캐시 버전 디렉토리(`public/build/ext/{v}/`)에 실파일로 게시해 웹서버가
* rewrite 전에 직접 서빙하게 한다 (#122). 병합 로직은 새로 만들지 않고
* 전부 기존 SSoT(TemplateService/ExtensionBundleService)를 호출한다.
*
* 원자성: `{v}.tmp/` 에 전부 쓴 뒤 디렉토리 rename → `{v}/`, manifest.json 은
* rename 후 마지막에 기록한다. manifest 존재 = 게시 완료(부분 게시 참조 방지).
*
* 실패 정책: 쓰기 실패는 예외를 삼키고 Log::warning + tmp 정리 — 사이트는
* API 폴백으로 정상 유지된다(fail-open 이 아니라 "정적 fast path 미적용" 상태).
*/
class ExtensionStaticCacheService
{
use ClearsTemplateCaches;
/** 게시 완료 마커 파일명 */
private const MANIFEST_FILE = 'manifest.json';
/** 게시 락 이름 접두사 */
private const LOCK_PREFIX = 'ext-static.publish.';
/** 확장 식별자 패턴 (vendor-name) — 경로 세그먼트 화이트리스트 */
private const IDENTIFIER_PATTERN = '/^[a-z0-9]+-[a-z0-9_]+$/';
/** 로케일 패턴 — 경로 세그먼트 화이트리스트 */
private const LOCALE_PATTERN = '/^[a-z]{2}(?:[-_][A-Za-z0-9]{2,8})?$/';
/** terminating 게시 예약 플래그 (프로세스당 1회) */
private static bool $publishScheduled = false;
/** 테스트 전용 — root 프로세스 판정 오버라이드 (null = 실판정) */
private static ?bool $rootProcessForTesting = null;
/** isPublished 요청당 메모이즈 (version => 존재 여부) */
private array $publishedMemo = [];
public function __construct(
private TemplateService $templateService,
private TemplateRepositoryInterface $templateRepository,
private ExtensionBundleService $bundleService,
private LanguagePackService $languagePackService,
) {}
/**
* 현재 확장 캐시 버전 기준으로 게시합니다.
*
* 이미 게시 완료(manifest 존재) 상태면 skip(멱등). `Cache::lock` 으로 단일
* 실행을 보장하며, 락 미획득 시 다른 프로세스가 게시 중인 것으로 보고 skip.
*
* @param bool $force 게시 완료 상태여도 강제 재게시
* @return bool 게시 완료 상태로 끝났으면 true (skip 포함), 실패/비활성이면 false
*/
public function publishCurrent(bool $force = false): bool
{
if (! $this->isEnabled()) {
return false;
}
$version = self::getExtensionCacheVersion();
if (! $force && $this->isPublished($version)) {
return true;
}
$lock = Cache::lock(self::LOCK_PREFIX.$version, 300);
if (! $lock->get()) {
return false;
}
try {
// 락 대기 중 다른 프로세스가 완료했을 수 있다 (멱등 재확인)
unset($this->publishedMemo[$version]);
if (! $force && $this->isPublished($version)) {
return true;
}
return $this->publishVersion($version);
} finally {
$lock->release();
}
}
/**
* 해당 버전이 게시 완료 상태인지 확인합니다 (manifest 존재 = 완료).
*
* AssetUrl 게이트가 요청당 여러 번 호출하므로 메모이즈한다.
*
* @param int $version 확장 캐시 버전
* @return bool 게시 완료 여부
*/
public function isPublished(int $version): bool
{
return $this->publishedMemo[$version] ??= is_file(
$this->versionDir($version).DIRECTORY_SEPARATOR.self::MANIFEST_FILE
);
}
/**
* 현재 버전 + 직전 1개를 보존하고 나머지 게시 디렉토리를 삭제합니다.
*
* 직전 버전을 남기는 이유: 브라우저에 캐시된 직전 렌더 HTML 이 아직 구버전
* 정적 URL 을 참조할 수 있다 (asset-url-recovery 파샬이 최후 방어).
*
* @return int 삭제된 디렉토리 수
*/
public function cleanup(): int
{
$base = $this->baseDir();
if (! File::isDirectory($base)) {
return 0;
}
$current = self::getExtensionCacheVersion();
$versions = [];
$deleted = 0;
foreach (File::directories($base) as $dir) {
$name = basename($dir);
// 미완료 tmp 잔존물은 무조건 제거 대상 (rename 전 실패 흔적)
if (str_ends_with($name, '.tmp')) {
File::deleteDirectory($dir);
$deleted++;
continue;
}
if (ctype_digit($name)) {
$versions[(int) $name] = $dir;
}
}
// 현재 버전과, 현재를 제외한 최신 1개(직전) 보존
$keep = [$current];
$others = array_keys($versions);
rsort($others);
foreach ($others as $v) {
if ($v !== $current) {
$keep[] = $v;
break;
}
}
foreach ($versions as $v => $dir) {
if (! in_array($v, $keep, true)) {
File::deleteDirectory($dir);
$deleted++;
}
}
return $deleted;
}
/**
* 수명주기 이벤트에서 호출되는 terminating 게시 예약.
*
* `incrementExtensionCacheVersion()` 내부 단일 지점에서 호출된다.
* 프로세스당 1회만 등록하며, 게시는 예약 시점이 아니라 **실행 시점의 현재
* 버전**으로 수행되어 연속 bump(일괄 업데이트)를 자연 병합한다.
*
* 프로덕션 전용 — 비프로덕션은 blade 가 정적 URL 을 방출하지 않으므로
* 게시 자체가 무의미하고(§2-2), testing 환경의 파일 쓰기 부수효과도 차단한다.
*/
public static function schedulePublishOnTerminate(): void
{
if (self::$publishScheduled || ! app()->environment('production')) {
return;
}
// root 프로세스(sudo 코어 업데이트 등)에서는 예약하지 않는다 — 게시가 만드는
// 캐시 락 샤드 디렉토리(storage/framework/cache/data/xx)와 병합 번들
// (storage/app/ext-bundles)이 root 소유로 남아, 이후 웹 프로세스의 캐시
// 쓰기가 그 샤드에 해시되는 순간 Permission denied 로 죽는다 (실사례:
// sudo 업데이트 직후 전면 500). normalizeOwnership 은 게시 트리(build/ext)만
// 다루므로 storage 측 부수 산출물은 회피가 정답이다. 게시는 다음 웹 렌더의
// 자가 치유(웹 계정)가 수행하고, 명시적 `ext-static:publish` 커맨드는 이
// 게이트를 거치지 않는다 (운영자 책임 — 규정 문서 §6).
if (self::isRootProcess()) {
return;
}
self::$publishScheduled = true;
app()->terminating(static function (): void {
// 실행 시점에 재무장 가능 상태로 복귀 — 요청마다 앱 인스턴스를 새로 쓰는
// 장수 프로세스(Octane 류)에서는 static 플래그만 살아남으므로, 리셋 없이는
// 2번째 이후 bump 가 새 앱에 콜백을 등록하지 못한 채 영구 미게시가 된다
// (자가 치유도 같은 플래그 공유). FPM(요청=프로세스)에서는 무영향.
self::$publishScheduled = false;
try {
app(self::class)->publishCurrent();
} catch (\Throwable $e) {
Log::warning('정적 게시 terminating 실행 실패 — 다음 렌더의 자가 치유가 재시도합니다', [
'error' => $e->getMessage(),
]);
}
});
}
/**
* 테스트 격리용 — terminating 예약 플래그를 초기화합니다.
*/
public static function resetPublishScheduleForTesting(): void
{
self::$publishScheduled = false;
self::$rootProcessForTesting = null;
}
/**
* 테스트 전용 — root 프로세스 판정을 강제합니다 (null 로 실판정 복귀).
*
* @param bool|null $isRoot 강제할 판정값
*/
public static function fakeRootProcessForTesting(?bool $isRoot): void
{
self::$rootProcessForTesting = $isRoot;
}
/**
* 현재 프로세스가 root(euid 0)로 실행 중인지 판정합니다.
*
* posix 확장이 없는 환경(Windows, 함수 비활성 호스팅)은 root 아님으로 본다.
*
* @return bool root 실행 여부
*/
private static function isRootProcess(): bool
{
if (self::$rootProcessForTesting !== null) {
return self::$rootProcessForTesting;
}
return function_exists('posix_geteuid') && posix_geteuid() === 0;
}
/**
* 게시 루트 디렉토리 절대 경로를 반환합니다.
*
* @return string `public/build/ext` 절대 경로
*/
public function baseDir(): string
{
return public_path('build/ext');
}
/**
* 버전 디렉토리 절대 경로를 반환합니다.
*
* @param int $version 확장 캐시 버전
* @return string 버전 디렉토리 절대 경로
*/
public function versionDir(int $version): string
{
return $this->baseDir().DIRECTORY_SEPARATOR.$version;
}
/**
* kill-switch 판정 (`core.static_cache.enabled`, .env `G7_STATIC_CACHE`).
*
* @return bool 정적 게시 활성 여부
*/
public function isEnabled(): bool
{
return (bool) config('core.static_cache.enabled', true);
}
/**
* 한 버전의 게시를 실제 수행합니다 (tmp 쓰기 → rename → manifest → GC).
*
* @param int $version 게시할 확장 캐시 버전
* @return bool 성공 여부
*/
private function publishVersion(int $version): bool
{
$base = $this->baseDir();
$tmp = $base.DIRECTORY_SEPARATOR.$version.'.tmp';
$final = $this->versionDir($version);
try {
File::deleteDirectory($tmp);
File::ensureDirectoryExists($tmp, 0775);
$files = [];
$this->writeHtaccess($tmp, $files);
$locales = $this->publishableLocales();
foreach ($this->templateRepository->getActive() as $template) {
$this->publishTemplate($tmp, $template, $locales, $files);
}
$this->publishBundles($tmp, $version, $files);
// 원자적 스왑 — force 재게시 시 기존 디렉토리를 비켜낸 뒤 rename
if (File::isDirectory($final)) {
File::deleteDirectory($final);
}
if (! @rename($tmp, $final)) {
throw new StaticCachePublishException("Failed to rename publish directory: {$tmp} -> {$final}");
}
// manifest 는 rename 후 마지막 기록 — 존재 = 게시 완료
$this->writeManifest($final, $version, $files);
unset($this->publishedMemo[$version]);
// sudo/root CLI 게시 대응 — terminating 게시는 코어 업데이트의
// restoreOwnership **이후**(프로세스 종료 시)에 실행되므로, root 소유로
// 남으면 이후 php-fpm 의 재게시·GC 가 영구 실패한다. 부모(public/build)
// 소유권을 상속시킨다 (FilePermissionHelper::copyFile 의 sudo 대응 선례).
$this->normalizeOwnership();
// 인라인 GC (현재 + 직전 1개 보존)
$this->cleanup();
Log::info('부트스트랩 리소스 정적 게시 완료', [
'version' => $version,
'files' => count($files),
]);
return true;
} catch (\Throwable $e) {
Log::warning('부트스트랩 리소스 정적 게시 실패 — API 폴백으로 동작합니다', [
'version' => $version,
'error' => $e->getMessage(),
]);
File::deleteDirectory($tmp);
return false;
}
}
/**
* root 로 실행된 CLI 게시의 산출물 소유권을 부모 디렉토리 기준으로 정상화합니다.
*
* root 가 아닌 프로세스는 chown 자체가 불가능하고 필요도 없다(자기 소유로 생성됨)
* — 그 경우 즉시 no-op. 실패는 chownRecursive 가 경고 로그로 누적한다.
* 게시 루트 전체(`build/ext`)를 대상으로 하므로 방금 게시된 버전 디렉토리와
* 잔존 구버전이 함께 정상화된다.
*/
private function normalizeOwnership(): void
{
if (! function_exists('chown') || ! function_exists('posix_geteuid') || posix_geteuid() !== 0) {
return;
}
$parent = dirname($this->baseDir());
$owner = @fileowner($parent);
$group = @filegroup($parent);
if ($owner === false || $owner === 0) {
return;
}
FilePermissionHelper::chownRecursive($this->baseDir(), $owner, $group);
}
/**
* 활성 템플릿 1개의 게시물(lang/components/routes/assets)을 기록합니다.
*
* @param string $tmp tmp 디렉토리 절대 경로
* @param Template $template 활성 템플릿
* @param array<string> $locales 게시 대상 로케일
* @param array<string> $files 기록된 상대 경로 누적 (참조)
*/
private function publishTemplate(string $tmp, Template $template, array $locales, array &$files): void
{
$identifier = (string) $template->identifier;
if (! preg_match(self::IDENTIFIER_PATTERN, $identifier)) {
Log::warning('정적 게시 제외 — 식별자 패턴 불일치', ['identifier' => $identifier]);
return;
}
$templateDir = $tmp.DIRECTORY_SEPARATOR.'templates'.DIRECTORY_SEPARATOR.$identifier;
// 1. lang 병합 결과 (코어→템플릿→모듈→플러그인→언어팩 훅) — 현 lang API 와 동일 형상(raw)
foreach ($locales as $locale) {
$result = $this->templateService->getLanguageDataWithModules($identifier, $locale);
if (! ($result['success'] ?? false) || ! is_array($result['data'] ?? null)) {
continue;
}
$this->writeJson(
$templateDir.DIRECTORY_SEPARATOR.'lang'.DIRECTORY_SEPARATOR.$locale.'.json',
$result['data'],
$files,
$tmp
);
}
// 2. components.json 사본 (raw)
$componentsPath = base_path("templates/{$identifier}/components.json");
if (is_file($componentsPath)) {
$this->copyFile($componentsPath, $templateDir.DIRECTORY_SEPARATOR.'components.json', $files, $tmp);
}
// 3. routes 병합 결과 — 프론트 소비 코드 무변경을 위한 성공 봉투 포함.
// 열화 스냅샷(확장 업데이트 진행 중 등)은 게시하지 않는다 — 정적 파일은
// 스스로 회복되지 않으므로 열화 상태가 다음 bump 까지 박제된다 (getRoutes 와 동일 규율)
$routesResult = $this->templateService->getRoutesDataWithModules($identifier);
if (($routesResult['success'] ?? false) && ! $this->templateService->lastRouteMergeWasDegraded()) {
$this->writeJson(
$templateDir.DIRECTORY_SEPARATOR.'routes.json',
['success' => true, 'message' => '', 'data' => $routesResult['data']],
$files,
$tmp
);
} elseif ($routesResult['success'] ?? false) {
Log::warning('정적 게시에서 routes 제외 — 라우트 병합 열화 상태 (확장 업데이트 진행 중 추정)', [
'template' => $identifier,
]);
}
// 4. dist 에셋 사본 (확장자 화이트리스트, *.map 제외)
$this->publishDistAssets(
base_path("templates/{$identifier}/dist"),
$templateDir.DIRECTORY_SEPARATOR.'assets',
$files,
$tmp
);
}
/**
* 템플릿 dist 디렉토리를 재귀 복사합니다 (허용 확장자만, 소스맵 제외).
*
* @param string $sourceDir 원본 dist 절대 경로
* @param string $targetDir 게시 대상 절대 경로
* @param array<string> $files 기록된 상대 경로 누적 (참조)
* @param string $tmp tmp 루트 (상대 경로 계산용)
*/
private function publishDistAssets(string $sourceDir, string $targetDir, array &$files, string $tmp): void
{
$realSource = realpath($sourceDir);
if ($realSource === false || ! is_dir($realSource)) {
return;
}
// 소스맵은 배포 금지 정책(`*.map` gitignore)과 동일하게 제외
$allowed = array_diff(AllowedTemplateFileType::getAllowedExtensions(), ['map']);
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($realSource, \FilesystemIterator::SKIP_DOTS)
);
/** @var \SplFileInfo $file */
foreach ($iterator as $file) {
if (! $file->isFile()) {
continue;
}
$extension = strtolower($file->getExtension());
if (! in_array($extension, $allowed, true)) {
continue;
}
// 컨테인먼트 검증 — 심볼릭 링크 등으로 dist 밖을 가리키는 실경로 차단
$realFile = $file->getRealPath();
if ($realFile === false || ! str_starts_with($realFile, $realSource.DIRECTORY_SEPARATOR)) {
continue;
}
$relative = substr($realFile, strlen($realSource) + 1);
$this->copyFile($realFile, $targetDir.DIRECTORY_SEPARATOR.$relative, $files, $tmp);
}
}
/**
* 확장 병합 번들 4종(modules/plugins × js/css)을 게시합니다.
*
* @param string $tmp tmp 디렉토리 절대 경로
* @param int $version 확장 캐시 버전
* @param array<string> $files 기록된 상대 경로 누적 (참조)
*/
private function publishBundles(string $tmp, int $version, array &$files): void
{
$map = ['module' => 'modules', 'plugin' => 'plugins'];
foreach ($map as $type => $plural) {
foreach (['js', 'css'] as $kind) {
$path = $this->bundleService->getBundleFilePath($type, $kind, $version);
if ($path === '' || ! is_file($path)) {
continue;
}
$this->copyFile(
$path,
$tmp.DIRECTORY_SEPARATOR.'bundles'.DIRECTORY_SEPARATOR."{$plural}.{$kind}",
$files,
$tmp
);
}
}
}
/**
* 게시 대상 로케일을 열거합니다 — `/api/locales/active` 와 동일 소스
* (언어팩이 추가한 로케일 포함).
*
* @return array<string> 로케일 목록 (패턴 검증 통과분)
*/
private function publishableLocales(): array
{
$locales = $this->languagePackService->getActiveLocales();
return array_values(array_filter(
is_array($locales) ? $locales : [],
static fn ($locale) => is_string($locale) && preg_match(self::LOCALE_PATTERN, $locale)
));
}
/**
* JSON 파일을 API 와 동일 인코딩 옵션으로 기록합니다.
*
* @param string $absolutePath 기록 대상 절대 경로
* @param mixed $data 직렬화할 데이터
* @param array<string> $files 기록된 상대 경로 누적 (참조)
* @param string $tmp tmp 루트 (상대 경로 계산용)
*/
private function writeJson(string $absolutePath, mixed $data, array &$files, string $tmp): void
{
File::ensureDirectoryExists(dirname($absolutePath), 0775);
$json = json_encode($data, ResponseHelper::JSON_ENCODE_OPTIONS);
if ($json === false) {
throw new StaticCachePublishException("Failed to encode JSON payload: {$absolutePath}");
}
if (File::put($absolutePath, $json) === false) {
throw new StaticCachePublishException("Failed to write file: {$absolutePath}");
}
$files[] = $this->relativePath($absolutePath, $tmp);
}
/**
* 파일 1개를 복사합니다.
*
* @param string $source 원본 절대 경로
* @param string $target 대상 절대 경로
* @param array<string> $files 기록된 상대 경로 누적 (참조)
* @param string $tmp tmp 루트 (상대 경로 계산용)
*/
private function copyFile(string $source, string $target, array &$files, string $tmp): void
{
File::ensureDirectoryExists(dirname($target), 0775);
if (! File::copy($source, $target)) {
throw new StaticCachePublishException("Failed to copy file: {$source} -> {$target}");
}
$files[] = $this->relativePath($target, $tmp);
}
/**
* Apache 용 불변 캐시 헤더 + 압축 .htaccess 를 기록합니다.
*
* 정적 서빙은 Laravel 압축 미들웨어(GzipEncodeResponse)를 우회하므로 압축을
* 여기서 직접 선언한다 — 없으면 종전 API 대비 전송량 회귀다 (실측: lang/ko.json
* 524,915B 비압축). nginx 는 서버 기본(ETag/Last-Modified) 재검증으로 충분하며
* 권장 gzip/expires 스니펫은 규정 문서(§8)에 안내한다.
*
* @param string $tmp tmp 디렉토리 절대 경로
* @param array<string> $files 기록된 상대 경로 누적 (참조)
*/
private function writeHtaccess(string $tmp, array &$files): void
{
$content = <<<'HTACCESS'
<IfModule mod_headers.c>
Header set Cache-Control "public, max-age=31536000, immutable"
</IfModule>
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE application/json application/javascript text/css image/svg+xml
</IfModule>
HTACCESS;
if (File::put($tmp.DIRECTORY_SEPARATOR.'.htaccess', $content) === false) {
throw new StaticCachePublishException('Failed to write .htaccess');
}
$files[] = '.htaccess';
}
/**
* 게시 완료 마커(manifest.json)를 기록합니다.
*
* @param string $finalDir 최종 버전 디렉토리 절대 경로
* @param int $version 확장 캐시 버전
* @param array<string> $files 게시된 상대 경로 목록
*/
private function writeManifest(string $finalDir, int $version, array $files): void
{
$manifest = [
'cache_version' => $version,
'published_at' => now()->toIso8601String(),
'files' => array_values($files),
];
$json = json_encode($manifest, ResponseHelper::JSON_ENCODE_OPTIONS);
if ($json === false || File::put($finalDir.DIRECTORY_SEPARATOR.self::MANIFEST_FILE, $json) === false) {
throw new StaticCachePublishException('Failed to write manifest');
}
}
/**
* tmp 루트 기준 상대 경로를 반환합니다.
*
* @param string $absolutePath 절대 경로
* @param string $tmp tmp 루트
* @return string 상대 경로 (구분자 `/` 정규화)
*/
private function relativePath(string $absolutePath, string $tmp): string
{
return str_replace('\\', '/', substr($absolutePath, strlen($tmp) + 1));
}
}
+147 -2
View File
@@ -2,6 +2,7 @@
namespace App\Support; namespace App\Support;
use App\Services\ExtensionStaticCacheService;
use App\Support\Routing\DualExtensionRoute; use App\Support\Routing\DualExtensionRoute;
/** /**
@@ -59,6 +60,23 @@ class AssetUrl
*/ */
private static ?string $modeOverride = null; private static ?string $modeOverride = null;
/**
* 정적 게시(bake) 베이스 경로 메모 (요청당 1회 판정).
*/
private static ?string $staticExtBaseMemo = null;
/**
* 정적 게시 베이스 판정 완료 여부.
*/
private static bool $staticExtBaseResolved = false;
/**
* 태그 계층 파일 단위 게이트 메모 (상대 경로 => 존재 여부).
*
* @var array<string, bool>
*/
private static array $staticFileMemo = [];
/** /**
* 현재 자산 URL 모드를 반환합니다. * 현재 자산 URL 모드를 반환합니다.
* *
@@ -103,6 +121,105 @@ class AssetUrl
self::$modeOverride = $mode; self::$modeOverride = $mode;
} }
/**
* 정적 게시(bake) 베이스 경로를 반환합니다 (#122).
*
* 게이트 3조건 — ① 프로덕션 ② `core.static_cache.enabled` ③ 현재 버전 게시
* 완료(manifest 존재) — 을 전부 통과할 때만 `/build/ext/{v}` 를 반환한다.
* 아니면 null (종전 API URL 방출). 요청당 1회 판정 후 메모이즈한다.
*
* 자가 치유 — 프로덕션인데 현재 버전이 미게시면 terminating 게시를 예약한다.
* 이번 응답은 종전 API URL 로 나가고(첫 방문자 1회만 종전 속도), 다음
* 렌더부터 정적 fast path 가 적용된다.
*
* blade 렌더 경로에서 호출되므로 어떤 실패도 예외로 새 나가면 안 된다.
*
* @return string|null 정적 베이스 경로 또는 null
*/
public static function staticExtBase(): ?string
{
if (self::$staticExtBaseResolved) {
return self::$staticExtBaseMemo;
}
self::$staticExtBaseResolved = true;
self::$staticExtBaseMemo = null;
try {
if (! app()->environment('production')) {
return null;
}
if (! (bool) config('core.static_cache.enabled', true)) {
return null;
}
// 트레이트 정적 메서드 직접 호출(ClearsTemplateCaches::)은 PHP 8.1+ E_DEPRECATED
// — 트레이트를 사용하는 클래스 경유로 호출한다 (게이트·게시자가 같은 static
// 스토어 메모 슬롯을 공유하게 되는 부수 이점도 있다)
$version = ExtensionStaticCacheService::getExtensionCacheVersion();
if (! app(ExtensionStaticCacheService::class)->isPublished($version)) {
// 자가 치유 — 응답 종료 후 게시 시도 (실패해도 API 폴백으로 정상)
ExtensionStaticCacheService::schedulePublishOnTerminate();
return null;
}
self::$staticExtBaseMemo = '/build/ext/'.$version;
} catch (\Throwable) {
return null;
}
return self::$staticExtBaseMemo;
}
/**
* 정적 게시 베이스 메모를 초기화합니다 (테스트 전용).
*/
public static function resetStaticExtBaseMemo(): void
{
self::$staticExtBaseResolved = false;
self::$staticExtBaseMemo = null;
self::$staticFileMemo = [];
}
/**
* 정적 게시본 내 파일 존재 여부를 확인합니다 (태그 계층 파일 단위 게이트).
*
* `<link>`/`<script>` 태그는 404 를 받아도 스스로 재시도하지 못하므로,
* manifest 게이트에 더해 그 자산의 실파일 존재까지 확인한 뒤에만 정적 URL 을
* 방출한다 (요청당 태그 대상 ~6개 파일, 메모이즈).
*
* @param string $relative 게시 트리 상대 경로
* @return bool 파일 존재 여부
*/
private static function staticFileExists(string $relative): bool
{
$version = substr((string) self::$staticExtBaseMemo, strlen('/build/ext/'));
return self::$staticFileMemo[$relative] ??= is_file(
public_path('build/ext/'.$version.'/'.$relative)
);
}
/**
* 태그 계층 자산의 정적 게시 URL 을 반환합니다 (파일 존재 확인 포함).
*
* @param string $relative 게시 트리 상대 경로
* @return string|null 정적 URL 또는 null (게이트 미통과)
*/
private static function staticTagUrl(string $relative): ?string
{
$base = self::staticExtBase();
if ($base === null || ! self::staticFileExists($relative)) {
return null;
}
return $base.'/'.$relative;
}
/** /**
* 템플릿 자산 URL 을 생성합니다. * 템플릿 자산 URL 을 생성합니다.
* *
@@ -112,11 +229,28 @@ class AssetUrl
* @param string $identifier 템플릿 식별자 * @param string $identifier 템플릿 식별자
* @param string $path `dist/` 이하 파일 경로 (예: `js/components.iife.js`) * @param string $path `dist/` 이하 파일 경로 (예: `js/components.iife.js`)
* @param int|string|null $version 캐시 무효화 버전 (null 이면 미부착) * @param int|string|null $version 캐시 무효화 버전 (null 이면 미부착)
* @param bool $allowStatic 정적 게시본(bake) 경로 허용 여부. 생성한 URL 이 정적
* 게시 GC(현재+직전 1개 보존)보다 오래 사는 저장소에 박제되는
* 호출부(SEO 페이지 캐시 등)는 false 로 종전 API URL 을 받는다
* @return string 생성된 URL * @return string 생성된 URL
*/ */
public static function templateAsset(string $identifier, string $path, int|string|null $version = null): string public static function templateAsset(string $identifier, string $path, int|string|null $version = null, bool $allowStatic = true): string
{ {
return self::asset('templates', $identifier, $path, $version); // 정적 게시본(bake) 우선 (#122) — 버전 디렉토리 경로라 `?v` 쿼리가 불필요하다.
// 게이트(프로덕션·kill-switch·게시 완료·개별 파일 존재) 미통과 시 종전 URL 그대로.
// `$version` 이 현재 게시 버전과 다르면 정적 분기를 건너뛴다 — 정적 경로는 항상
// 현재 게시본이므로, 다른 버전을 명시한 호출에 현재본을 주면 "요청 버전이 URL 에
// 반영된다" 는 시그니처 계약이 조용히 깨진다 (현 호출부는 전부 현재 버전 전달).
$static = null;
if ($allowStatic) {
$base = self::staticExtBase();
if ($base !== null && ($version === null || $base === '/build/ext/'.$version)) {
$static = self::staticTagUrl('templates/'.$identifier.'/assets/'.ltrim($path, '/'));
}
}
return $static ?? self::asset('templates', $identifier, $path, $version);
} }
/** /**
@@ -178,6 +312,17 @@ class AssetUrl
*/ */
public static function extensionBundle(string $type, string $kind, int|string|null $version = null): string public static function extensionBundle(string $type, string $kind, int|string|null $version = null): string
{ {
// 정적 게시본(bake) 우선 (#122) — `bundles/{modules|plugins}.{js|css}` 사본.
// `$version` 명시 호출이 현재 게시 버전과 다르면 건너뛴다 (templateAsset 과 동일 계약)
$base = self::staticExtBase();
$static = $base !== null && ($version === null || $base === '/build/ext/'.$version)
? self::staticTagUrl("bundles/{$type}.{$kind}")
: null;
if ($static !== null) {
return $static;
}
$base = self::isExtensionless() $base = self::isExtensionless()
? "/api/{$type}/bundle/{$kind}" ? "/api/{$type}/bundle/{$kind}"
: "/api/{$type}/bundle.{$kind}"; : "/api/{$type}/bundle.{$kind}";
+14 -4
View File
@@ -231,7 +231,7 @@ return [
| |
*/ */
'version' => env('APP_VERSION', '7.0.9'), 'version' => env('APP_VERSION', '7.0.10'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@@ -271,7 +271,10 @@ return [
'github_token' => env('G7_UPDATE_GITHUB_TOKEN', ''), 'github_token' => env('G7_UPDATE_GITHUB_TOKEN', ''),
'pending_path' => env('G7_UPDATE_PENDING_PATH') ?: storage_path('app/core_pending'), 'pending_path' => env('G7_UPDATE_PENDING_PATH') ?: storage_path('app/core_pending'),
'targets' => array_filter(array_map('trim', explode(',', env('G7_UPDATE_TARGETS', 'app,bootstrap,config,database,docs,lang,lang-packs/_bundled,resources,routes,public,tests,upgrades,artisan,composer.json,composer.json.default,composer.lock,package.json,package-lock.json,vite.config.js,vite.config.core.js,vite.config.editor.js,vite.config.devtools.js,vitest.config.ts,playwright.config.ts,tsconfig.json,phpunit.xml,pint.json,.editorconfig,.gitattributes,.gitignore,README.md,README.ko.md,CHANGELOG.md,modules/_bundled,plugins/_bundled,templates/_bundled')))), 'targets' => array_filter(array_map('trim', explode(',', env('G7_UPDATE_TARGETS', 'app,bootstrap,config,database,docs,lang,lang-packs/_bundled,resources,routes,public,tests,upgrades,artisan,composer.json,composer.json.default,composer.lock,package.json,package-lock.json,vite.config.js,vite.config.core.js,vite.config.editor.js,vite.config.devtools.js,vitest.config.ts,playwright.config.ts,tsconfig.json,phpunit.xml,pint.json,.editorconfig,.gitattributes,.gitignore,README.md,README.ko.md,CHANGELOG.md,modules/_bundled,plugins/_bundled,templates/_bundled')))),
'excludes' => array_filter(array_map('trim', explode(',', env('G7_UPDATE_EXCLUDES', 'node_modules,.git,bootstrap/cache')))), // build/ext: 부트스트랩 리소스 정적 게시본(#122) — 각 서버가 재생성하는 로컬 파생물이라
// 릴리즈 소스에 없다. 제외하지 않으면 --prune 업데이트가 orphan 으로 삭제해
// 업데이트 완료까지 정적 fast path 가 불필요하게 끊긴다 (백업 대상에서도 제외).
'excludes' => array_filter(array_map('trim', explode(',', env('G7_UPDATE_EXCLUDES', 'node_modules,.git,bootstrap/cache,build/ext')))),
// applyUpdate 의 "신규 최상위 항목 자동 발견" 폴백이 절대 덮어쓰면 안 되는 경로 목록. // applyUpdate 의 "신규 최상위 항목 자동 발견" 폴백이 절대 덮어쓰면 안 되는 경로 목록.
// 런타임 데이터(`storage`), 로컬 환경(`.env*`), 별도 파이프라인 산출물(`vendor`), // 런타임 데이터(`storage`), 로컬 환경(`.env*`), 별도 파이프라인 산출물(`vendor`),
// 개발 도구 메타(`.git`, `.claude`, `.codex`, `.agents`, `.serena` 등) 를 보호한다. // 개발 도구 메타(`.git`, `.claude`, `.codex`, `.agents`, `.serena` 등) 를 보호한다.
@@ -309,10 +312,15 @@ return [
// 백업 내부 모듈 storage 에 `.preserve-ownership` 마커가 있으면 그 서브트리만 // 백업 내부 모듈 storage 에 `.preserve-ownership` 마커가 있으면 그 서브트리만
// 자동 skip 되어 부작용 없음. // 자동 skip 되어 부작용 없음.
// //
// `public/build/ext`: 부트스트랩 리소스 정적 게시본(#122) — sudo update 종료 시
// terminating 게시가 root 소유 버전 디렉토리를 만들면 이후 php-fpm 의 재게시·GC 가
// Permission denied 로 실패해 정적 fast path 가 영구 꺼진다(사이트는 API 폴백으로 정상).
// 임시 산출물(사용자 데이터 아님)이므로 extension_backups 와 같은 근거로 chown 대상.
//
// 환경변수 `G7_UPDATE_RESTORE_OWNERSHIP` 로 공유 호스팅 등 축소 필요 시 재정의 가능. // 환경변수 `G7_UPDATE_RESTORE_OWNERSHIP` 로 공유 호스팅 등 축소 필요 시 재정의 가능.
'restore_ownership' => array_filter(array_map('trim', explode(',', env( 'restore_ownership' => array_filter(array_map('trim', explode(',', env(
'G7_UPDATE_RESTORE_OWNERSHIP', 'G7_UPDATE_RESTORE_OWNERSHIP',
'storage/logs,storage/framework,storage/app/core_pending,storage/app/extension_backups,storage/app/core_backups,bootstrap/cache,vendor,modules,modules/_pending,plugins,plugins/_pending,templates,templates/_pending,lang-packs,lang-packs/_pending' 'storage/logs,storage/framework,storage/app/core_pending,storage/app/extension_backups,storage/app/core_backups,bootstrap/cache,vendor,modules,modules/_pending,plugins,plugins/_pending,templates,templates/_pending,lang-packs,lang-packs/_pending,public/build/ext'
)))), )))),
// 7.0.0-beta.3+: 그룹 쓰기 권한 비대칭 정상화 대상. // 7.0.0-beta.3+: 그룹 쓰기 권한 비대칭 정상화 대상.
// sudo root 로 실행된 업데이트가 umask 022 로 신규 생성한 하위 디렉토리/파일이 // sudo root 로 실행된 업데이트가 umask 022 로 신규 생성한 하위 디렉토리/파일이
@@ -341,9 +349,11 @@ return [
// //
// 자식 디렉토리(예: plugins/sirsoft-*)는 syncGroupWritability 가 재귀 순회하여 // 자식 디렉토리(예: plugins/sirsoft-*)는 syncGroupWritability 가 재귀 순회하여
// 자동 정상화되므로 상위 루트만 지정하면 충분. 환경변수로 재정의 가능. // 자동 정상화되므로 상위 루트만 지정하면 충분. 환경변수로 재정의 가능.
// - public/build/ext: 정적 게시본(#122) — php-fpm 이 재게시/GC 를 수행하므로
// restore_ownership 과 같은 근거로 g+w 정상화 대상.
'restore_ownership_group_writable' => array_filter(array_map('trim', explode(',', env( 'restore_ownership_group_writable' => array_filter(array_map('trim', explode(',', env(
'G7_UPDATE_RESTORE_OWNERSHIP_GROUP_WRITABLE', 'G7_UPDATE_RESTORE_OWNERSHIP_GROUP_WRITABLE',
'storage/logs,storage/framework,storage/app/core_pending,storage/app/extension_backups,storage/app/core_backups,bootstrap/cache,vendor,modules,modules/_pending,plugins,plugins/_pending,templates,templates/_pending,lang-packs,lang-packs/_pending' 'storage/logs,storage/framework,storage/app/core_pending,storage/app/extension_backups,storage/app/core_backups,bootstrap/cache,vendor,modules,modules/_pending,plugins,plugins/_pending,templates,templates/_pending,lang-packs,lang-packs/_pending,public/build/ext'
)))), )))),
// spawn 자식 프로세스 실패 시 동작 모드. // spawn 자식 프로세스 실패 시 동작 모드.
+15
View File
@@ -105,6 +105,21 @@ return [
'max_page' => 1000, 'max_page' => 1000,
], ],
/*
|--------------------------------------------------------------------------
| 부트스트랩 리소스 정적 게시 (bake)
|--------------------------------------------------------------------------
| 초기 부트스트랩 리소스(다국어 병합·컴포넌트 정의·라우트·확장 번들·템플릿
| dist 에셋)를 캐시 버전 디렉토리(`public/build/ext/{v}/`)에 실파일로 게시해
| 웹서버가 rewrite 전에 직접 서빙하는 fast path 의 스위치입니다.
|
| 끄면(false) 게시가 중단되고 blade 가 정적 URL 을 방출하지 않아 전면 API
| 폴백(종전 동작)으로 돌아갑니다. 이미 게시된 파일은 참조되지 않은 채 남습니다.
*/
'static_cache' => [
'enabled' => env('G7_STATIC_CACHE', true),
],
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| 아웃바운드 프록시 연결 테스트 | 아웃바운드 프록시 연결 테스트
+1
View File
@@ -82,6 +82,7 @@ return [
'notification:cleanup' => ['options' => []], 'notification:cleanup' => ['options' => []],
'layout-previews:cleanup' => ['options' => []], 'layout-previews:cleanup' => ['options' => []],
'ext-bundles:cleanup' => ['options' => []], 'ext-bundles:cleanup' => ['options' => []],
'ext-static:cleanup' => ['options' => []],
'seo:prune-stats' => ['options' => ['days']], 'seo:prune-stats' => ['options' => ['days']],
'schedules:prune-history' => ['options' => ['days']], 'schedules:prune-history' => ['options' => ['days']],
'identity:expire-challenges' => ['options' => []], 'identity:expire-challenges' => ['options' => []],
+3 -2
View File
@@ -9,7 +9,7 @@
| 카테고리 | 문서 수 | 링크 상태 | | 카테고리 | 문서 수 | 링크 상태 |
|----------|---------|----------| |----------|---------|----------|
| [백엔드](backend/) | 35개 | 정상 | | [백엔드](backend/) | 36개 | 정상 |
| [프론트엔드](frontend/) | 51개 | 정상 | | [프론트엔드](frontend/) | 51개 | 정상 |
| [확장 시스템](extension/) | 31개 | 정상 | | [확장 시스템](extension/) | 31개 | 정상 |
| 공통 | 20개 | 정상 | | 공통 | 20개 | 정상 |
@@ -125,7 +125,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답
<!-- AUTO-GENERATED-START: docs-readme-full-list --> <!-- AUTO-GENERATED-START: docs-readme-full-list -->
## 카테고리별 전체 문서 목록 ## 카테고리별 전체 문서 목록
### 백엔드 (35개) ### 백엔드 (36개)
| 문서 | 제목 | | 문서 | 제목 |
|------|------| |------|------|
@@ -161,6 +161,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답
| [service-provider.md](backend/service-provider.md) | 서비스 프로바이더 안전성 | | [service-provider.md](backend/service-provider.md) | 서비스 프로바이더 안전성 |
| [service-repository.md](backend/service-repository.md) | Service-Repository 패턴 | | [service-repository.md](backend/service-repository.md) | Service-Repository 패턴 |
| [settings-multilingual-enrichment.md](backend/settings-multilingual-enrichment.md) | Settings 카탈로그 다국어 자동 보강 | | [settings-multilingual-enrichment.md](backend/settings-multilingual-enrichment.md) | Settings 카탈로그 다국어 자동 보강 |
| [static-asset-publishing.md](backend/static-asset-publishing.md) | 부트스트랩 리소스 정적 게시 (Static Asset Publishing) |
| [translatable-seeders.md](backend/translatable-seeders.md) | 다국어 시더 인터페이스 (Translatable Seeders) | | [translatable-seeders.md](backend/translatable-seeders.md) | 다국어 시더 인터페이스 (Translatable Seeders) |
| [user-overrides.md](backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) | | [user-overrides.md](backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) |
| [validation.md](backend/validation.md) | 검증 (Validation) | | [validation.md](backend/validation.md) | 검증 (Validation) |
+1
View File
@@ -61,6 +61,7 @@
| [service-provider.md](service-provider.md) | 서비스 프로바이더 안전성 | DB 접근 전 .env 파일 존재 확인 필수 | | [service-provider.md](service-provider.md) | 서비스 프로바이더 안전성 | DB 접근 전 .env 파일 존재 확인 필수 |
| [service-repository.md](service-repository.md) | Service-Repository 패턴 | RepositoryInterface 주입 필수 (구체 클래스 직접 주입 금지) | | [service-repository.md](service-repository.md) | Service-Repository 패턴 | RepositoryInterface 주입 필수 (구체 클래스 직접 주입 금지) |
| [settings-multilingual-enrichment.md](settings-multilingual-enrichment.md) | Settings 카탈로그 다국어 자동 보강 | settings JSON 의 다국어 카탈로그 라벨(_cached_name 등)은 카탈... | | [settings-multilingual-enrichment.md](settings-multilingual-enrichment.md) | Settings 카탈로그 다국어 자동 보강 | settings JSON 의 다국어 카탈로그 라벨(_cached_name 등)은 카탈... |
| [static-asset-publishing.md](static-asset-publishing.md) | 부트스트랩 리소스 정적 게시 (Static Asset Publishing) | 게시물: public/build/ext/{cache_version}/ — 수명주기 이... |
| [translatable-seeders.md](translatable-seeders.md) | 다국어 시더 인터페이스 (Translatable Seeders) | 다국어 JSON 컬럼(name 등)을 시드하는 확장 entity 시더는 Transla... | | [translatable-seeders.md](translatable-seeders.md) | 다국어 시더 인터페이스 (Translatable Seeders) | 다국어 JSON 컬럼(name 등)을 시드하는 확장 entity 시더는 Transla... |
| [user-overrides.md](user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) | 모델에 `use HasUserOverrides;` + `protected array ... | | [user-overrides.md](user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) | 모델에 `use HasUserOverrides;` + `protected array ... |
| [validation.md](validation.md) | 검증 (Validation) | 필수: FormRequest에서 검증 (Service에 검증 로직 배치 금지) | | [validation.md](validation.md) | 검증 (Validation) | 필수: FormRequest에서 검증 (Service에 검증 로직 배치 금지) |
+2 -2
View File
@@ -2302,7 +2302,7 @@ HTTP/1.1 200
<!-- @generated:end --> <!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 --> **설명** 모듈 컴포넌트 매니페스트(`GET /api/modules/{identifier}/components.json`)의 확장자 없는 이중 모드 변형입니다. 응답·캐시·폴백 동작이 확장자 형태와 동일하며, `.json` 주소를 가로채는 정적 파일 최적화 서버 설정에서 프론트가 이 형태로 자동 전환합니다 (자산 URL 이중 모드).
### GET /api/modules/{identifier}/components.json ### GET /api/modules/{identifier}/components.json
@@ -2373,7 +2373,7 @@ HTTP/1.1 200
<!-- @generated:end --> <!-- @generated:end -->
**설명** 모듈의 컴포넌트 정의 파일(components.json)을 서빙하는 공개 엔드포인트입니다. 인증이 필요하지 않습니다. 편집 모드 부팅 시 ComponentRegistry 가 활성 확장 매니페스트를 네임스페이스 병합하기 위해 fetch 하며, 구버전 모듈처럼 파일이 없으면 빈 components 로 폴백합니다. 응답은 1시간 캐시됩니다. 모듈 미존재 시 404. **설명** 모듈의 컴포넌트 정의 파일(components.json)을 서빙하는 공개 엔드포인트입니다. 인증이 필요하지 않습니다. 편집 모드 부팅 시 ComponentRegistry 가 활성 확장 매니페스트를 네임스페이스 병합하기 위해 fetch 하며, 구버전 모듈처럼 파일이 없으면 빈 components 로 폴백합니다. 조건부 캐시가 적용됩니다 — 응답에 ETag 가 부착되며 `If-None-Match` 일치 시 본문 없는 `304` 를 반환하고, Cache-Control 은 프로덕션 `public, max-age=3600` / 그 외 환경 `no-cache` 로 분기합니다. 모듈 미존재 시 404.
### GET /api/modules/{identifier}/editor-spec ### GET /api/modules/{identifier}/editor-spec
+1 -1
View File
@@ -2660,7 +2660,7 @@ Cache-Control: public, max-age=3600
<!-- @generated:end --> <!-- @generated:end -->
**설명** 플러그인의 컴포넌트 정의 파일(components.json)을 서빙하는 공개 엔드포인트입니다. 인증이 필요하지 않습니다. 편집 모드 부팅 시 ComponentRegistry 가 활성 확장 매니페스트를 네임스페이스 병합하기 위해 fetch 하며, 구버전 플러그인처럼 파일이 없으면 빈 components 로 폴백합니다. 응답은 1시간 캐시됩니다. 플러그인 미존재 시 404. **설명** 플러그인의 컴포넌트 정의 파일(components.json)을 서빙하는 공개 엔드포인트입니다. 인증이 필요하지 않습니다. 편집 모드 부팅 시 ComponentRegistry 가 활성 확장 매니페스트를 네임스페이스 병합하기 위해 fetch 하며, 구버전 플러그인처럼 파일이 없으면 빈 components 로 폴백합니다. 조건부 캐시가 적용됩니다 — 응답에 ETag 가 부착되며 `If-None-Match` 일치 시 본문 없는 `304` 를 반환하고, Cache-Control 은 프로덕션 `public, max-age=3600` / 그 외 환경 `no-cache` 로 분기합니다. 플러그인 미존재 시 404.
### GET /api/plugins/{identifier}/editor-spec ### GET /api/plugins/{identifier}/editor-spec
+6 -6
View File
@@ -97259,7 +97259,7 @@ HTTP/1.1 200
<!-- @generated:end --> <!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 --> **설명** 템플릿 컴포넌트 정의(`GET /api/templates/{identifier}/components.json`)의 확장자 없는 이중 모드 변형입니다. 응답·캐시·폴백 동작이 확장자 형태와 동일하며, `.json` 주소를 가로채는 정적 파일 최적화 서버 설정에서 프론트가 이 형태로 자동 전환합니다 (자산 URL 이중 모드).
### GET /api/templates/{identifier}/components.json ### GET /api/templates/{identifier}/components.json
@@ -97327,7 +97327,7 @@ HTTP/1.1 200
<!-- @generated:end --> <!-- @generated:end -->
**설명** 템플릿의 컴포넌트 정의(components.json)를 서빙하는 공개 엔드포인트입니다(TemplateService::getComponentsFilePath). 프론트엔드 렌더 엔진 부팅에 사용하며 1시간 캐시됩니다. 인증이 필요 없습니다. **설명** 템플릿의 컴포넌트 정의(components.json)를 서빙하는 공개 엔드포인트입니다(TemplateService::getComponentsFilePath). 프론트엔드 렌더 엔진 부팅에 사용합니다. 조건부 캐시가 적용됩니다 — 응답에 ETag 가 부착되며 `If-None-Match` 일치 시 본문 없는 `304` 를 반환하고, Cache-Control 은 프로덕션 `public, max-age=3600` / 그 외 환경 `no-cache`(파일 수정 즉시 반영)로 분기합니다. 정적 게시본(`/build/ext/{v}/…`)이 있으면 프론트는 그 정적 파일을 우선 수신하며, 본 API 는 미게시/부분게시/GC 직후의 **폴백 경로**입니다. 인증이 필요 없습니다.
### GET /api/templates/{identifier}/config ### GET /api/templates/{identifier}/config
@@ -189484,7 +189484,7 @@ HTTP/1.1 200
<!-- @generated:end --> <!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 --> **설명** 템플릿 다국어(`GET /api/templates/{identifier}/lang/{locale}.json`)의 확장자 없는 이중 모드 변형입니다. 응답·캐시·폴백 동작이 확장자 형태와 동일하며, `.json` 주소를 가로채는 정적 파일 최적화 서버 설정에서 프론트가 이 형태로 자동 전환합니다 (자산 URL 이중 모드).
### GET /api/templates/{identifier}/lang/{locale}.json ### GET /api/templates/{identifier}/lang/{locale}.json
@@ -189539,7 +189539,7 @@ _이 엔드포인트는 `success`/`message`/`data` 봉투를 사용하지 않습
<!-- @generated:end --> <!-- @generated:end -->
**설명** 활성 템플릿의 다국어 파일(lang/{locale}.json)을 활성화된 모듈의 다국어 데이터와 병합해 서빙하는 공개 엔드포인트입니다(TemplateService::getLanguageDataWithModules). 지원하지 않는 로케일/파일 부재 시 404이며 1시간 캐시됩니다. 인증이 필요 없습니다. **설명** 활성 템플릿의 다국어 파일(lang/{locale}.json)을 활성화된 모듈의 다국어 데이터와 병합해 서빙하는 공개 엔드포인트입니다(TemplateService::getLanguageDataWithModules). 지원하지 않는 로케일/파일 부재 시 404입니다. 조건부 캐시가 적용됩니다 — 응답에 ETag 가 부착되며 `If-None-Match` 일치 시 본문 없는 `304` 를 반환하고, Cache-Control 은 프로덕션 `public, max-age=3600` / 그 외 환경 `no-cache` 로 분기합니다. 정적 게시본(`/build/ext/{v}/…`)이 있으면 프론트는 그 정적 파일을 우선 수신하며, 본 API 는 미게시/부분게시/GC 직후의 **폴백 경로**입니다. 인증이 필요 없습니다.
### GET /api/templates/{identifier}/layout-attachments/{attachment}/file ### GET /api/templates/{identifier}/layout-attachments/{attachment}/file
@@ -189705,7 +189705,7 @@ HTTP/1.1 200
<!-- @generated:end --> <!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 --> **설명** 템플릿 라우트(`GET /api/templates/{identifier}/routes.json`)의 확장자 없는 이중 모드 변형입니다. 응답·캐시·폴백 동작이 확장자 형태와 동일하며, `.json` 주소를 가로채는 정적 파일 최적화 서버 설정에서 프론트가 이 형태로 자동 전환합니다 (자산 URL 이중 모드).
### GET /api/templates/{identifier}/routes.json ### GET /api/templates/{identifier}/routes.json
@@ -189773,6 +189773,6 @@ HTTP/1.1 200
<!-- @generated:end --> <!-- @generated:end -->
**설명** 템플릿의 라우트 정의(routes.json)를 활성화된 모듈의 라우트와 병합해 서빙하는 공개 엔드포인트입니다(TemplateService::getRoutesDataWithModules). 프론트엔드 라우팅 부팅에 사용하며 `v` query로 캐시를 무효화하고 1시간 캐시됩니다. 인증이 필요 없습니다. **설명** 템플릿의 라우트 정의(routes.json)를 활성화된 모듈의 라우트와 병합해 서빙하는 공개 엔드포인트입니다(TemplateService::getRoutesDataWithModules). 프론트엔드 라우팅 부팅에 사용합니다. 캐시 헤더는 **`v` 쿼리를 명시한 요청에만** 적용됩니다 — 버전 키드 URL 은 확장 변경 시 URL 자체가 바뀌므로 ETag + `If-None-Match` 304 + Cache-Control(프로덕션 `public, max-age=3600` / 그 외 `no-cache`)이 안전하고, 무버전 요청(핸드셰이크 폴백)은 종전대로 캐시 헤더 없이 신선 응답합니다. 단 라우트 병합이 열화 상태(확장 업데이트 진행 중 등)면 `v` 명시 요청이어도 공개 캐시 헤더를 부여하지 않습니다 — 열화 응답이 브라우저/CDN 에 1시간 박제되는 것을 막습니다(서버측 캐시 회피와 동일 규율). 정적 게시본(`/build/ext/{v}/…`)이 있으면 프론트는 그 정적 파일을 우선 수신하며, 본 API 는 미게시/부분게시/GC 직후의 **폴백 경로**입니다. 인증이 필요 없습니다.
+3 -1
View File
@@ -1114,7 +1114,7 @@ SEO 렌더링은 `renderComponent()` 진입 시점에 이 값을 해석해 데
| `if` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::evaluateBooleanExpression` | 거짓 판정: `''`, `false`, `0`, `null`, `undefined` (대소문자·공백 무시) | | `if` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::evaluateBooleanExpression` | 거짓 판정: `''`, `false`, `0`, `null`, `undefined` (대소문자·공백 무시) |
| `condition` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::shouldRender` | `if` 의 별칭 | | `condition` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::shouldRender` | `if` 의 별칭 |
| `conditions` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::evaluateConditions` | 문자열 / `{and:[]}` / `{or:[]}` / `[{if:…}]` 체인. 빈 AND=참, 빈 OR=거짓. 어느 형식도 아니면 **렌더링**(양쪽 동일 — 숨기면 봇 화면에서만 사라짐) | | `conditions` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::evaluateConditions` | 문자열 / `{and:[]}` / `{or:[]}` / `[{if:…}]` 체인. 빈 AND=참, 빈 OR=거짓. 어느 형식도 아니면 **렌더링**(양쪽 동일 — 숨기면 봇 화면에서만 사라짐) |
| `iteration` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::renderIteration` | `item_var` / `index_var` 별칭 포함 | | `iteration` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::renderIteration` | `item_var` / `index_var` 별칭 포함. 자동 변수 `{item_var}_index` 도 양쪽 동일 주입 (engine-v1.56.0 패리티) |
| `type: "iterator"` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::normalizeIteratorNode` | `data`/`itemName`/`indexName` → `iteration` 변환 | | `type: "iterator"` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::normalizeIteratorNode` | `data`/`itemName`/`indexName` → `iteration` 변환 |
| `classMap` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::resolveClassMap` | | | `classMap` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::resolveClassMap` | |
| `responsive` | 전체 브레이크포인트 | 데스크톱 폭만 | `app/Seo/ComponentHtmlMapper.php::applyResponsiveOverrides` / `matchingBreakpointKey` | 봇=데스크톱 고정. `props`/`if`/`text`/`children`/`iteration` 오버라이드 반영. 매칭 키가 여럿이면 **하나만** 적용 — 커스텀 범위 > 프리셋, 좁은 범위 > 넓은 범위 (양쪽 동일) | | `responsive` | 전체 브레이크포인트 | 데스크톱 폭만 | `app/Seo/ComponentHtmlMapper.php::applyResponsiveOverrides` / `matchingBreakpointKey` | 봇=데스크톱 고정. `props`/`if`/`text`/`children`/`iteration` 오버라이드 반영. 매칭 키가 여럿이면 **하나만** 적용 — 커스텀 범위 > 프리셋, 좁은 범위 > 넓은 범위 (양쪽 동일) |
@@ -1131,11 +1131,13 @@ SEO 렌더링은 `renderComponent()` 진입 시점에 이 값을 해석해 데
| `isolatedState` / `$parent` / `_isolated` | ✅ | ❌ (무해) | 미처리 | 격리·모달 부모 컨텍스트 | | `isolatedState` / `$parent` / `_isolated` | ✅ | ❌ (무해) | 미처리 | 격리·모달 부모 컨텍스트 |
| `blur_until_loaded` / 노드 최상위 `style` | ✅ | ❌ (무해) | 미처리 | 표현 전용 | | `blur_until_loaded` / 노드 최상위 `style` | ✅ | ❌ (무해) | 미처리 | 표현 전용 |
| 노드 최상위에 잘못 놓인 컴포넌트 prop (`size` 등) | ❌ | ❌ | 미처리 | 양쪽 모두 `props` 객체만 읽으므로 무시됨 — 값을 적용하려면 `props` 안으로 옮겨야 함 | | 노드 최상위에 잘못 놓인 컴포넌트 prop (`size` 등) | ❌ | ❌ | 미처리 | 양쪽 모두 `props` 객체만 읽으므로 무시됨 — 값을 적용하려면 `props` 안으로 옮겨야 함 |
| `comment` / `_comment` 접두 계열 (`_comment_id` 등) | ❌ | ❌ | 미처리 | 개발자 주석 메타 — 양쪽 렌더러 모두 무시. `_comment` 는 접두사 계열로 판정한다 (`SeoNodeKeyParityTest::isCommentKey`) |
| `props.isHtml` (콘텐츠 노드) | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::renderRawMode` | 거짓이면 이스케이프, 참(기본)이면 정화 후 HTML — "봇 화면의 HTML 정화" 참조 | | `props.isHtml` (콘텐츠 노드) | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::renderRawMode` | 거짓이면 이스케이프, 참(기본)이면 정화 후 HTML — "봇 화면의 HTML 정화" 참조 |
| `props.value` (폼 제어) | 속성 | 속성 | `app/Seo/ComponentHtmlMapper.php::resolveTextContent` | `select`/`option`/`input`/`textarea` 등에서는 글자로 승계하지 않음 — 선택 목록은 `options` 로 항목 라벨을 그림 | | `props.value` (폼 제어) | 속성 | 속성 | `app/Seo/ComponentHtmlMapper.php::resolveTextContent` | `select`/`option`/`input`/`textarea` 등에서는 글자로 승계하지 않음 — 선택 목록은 `options` 로 항목 라벨을 그림 |
| `props.purifyConfig` | ✅ | ❌ (의도) | 미처리 | 봇 화면은 기본 정화 규칙만 적용 | | `props.purifyConfig` | ✅ | ❌ (의도) | 미처리 | 봇 화면은 기본 정화 규칙만 적용 |
| `modals` | ✅ | ❌ (무해) | `SeoRenderer` 가 `components` 만 렌더 | 봇 화면은 모달을 렌더하지 않음 | | `modals` | ✅ | ❌ (무해) | `SeoRenderer` 가 `components` 만 렌더 | 봇 화면은 모달을 렌더하지 않음 |
| 레이아웃 최상위 `state` / `initLocal` / `initGlobal` | ✅ | ✅ | `app/Seo/SeoRenderer.php::resolveInitStateBlock` / `resolveInitActionState` | `init_actions` 의 상태 설정(로컬/전역)도 반영 | | 레이아웃 최상위 `state` / `initLocal` / `initGlobal` | ✅ | ✅ | `app/Seo/SeoRenderer.php::resolveInitStateBlock` / `resolveInitActionState` | `init_actions` 의 상태 설정(로컬/전역)도 반영 |
| **데이터소스 레벨** `initLocal` 옵션 | ✅ | ❌ (의도) | 미처리 | 봇 화면 미지원 확정 (2026-08-25) — 이 옵션을 쓰는 화면(장바구니·주문서·프로필 수정·게시판 작성 폼 등)은 인증·인터랙션 화면이라 봇 렌더 가치가 없다. 봇 노출이 필요한 상태 시드는 레이아웃 최상위 `initLocal`/`state` 를 사용한다 |
이 표는 `tests/Unit/Seo/SeoNodeKeyParityTest.php` 의 분류 목록과 동기 유지합니다. 표를 바꾸면 그 테스트의 목록도 함께 바꿔야 합니다. 반대로 레이아웃에 새 노드 키가 등장하면 그 테스트가 실패하므로, 봇 화면에서 해석이 필요한지 판단한 뒤 양쪽을 갱신하세요. 이 표는 `tests/Unit/Seo/SeoNodeKeyParityTest.php` 의 분류 목록과 동기 유지합니다. 표를 바꾸면 그 테스트의 목록도 함께 바꿔야 합니다. 반대로 레이아웃에 새 노드 키가 등장하면 그 테스트가 실패하므로, 봇 화면에서 해석이 필요한지 판단한 뒤 양쪽을 갱신하세요.
+109
View File
@@ -0,0 +1,109 @@
# 부트스트랩 리소스 정적 게시 (Static Asset Publishing)
초기 부트스트랩 리소스(다국어 병합·컴포넌트 정의·라우트 정보·확장 병합 번들·템플릿 dist 에셋)를 캐시 버전 디렉토리 기반의 실파일로 `public/` 하위에 게시(bake)하고, 웹서버가 rewrite 전에 직접 서빙하는 fast path 를 다룬다. (공개 #122)
## TL;DR (5초 요약)
```text
1. 게시물: public/build/ext/{cache_version}/ — 수명주기 이벤트마다 terminating 훅이 재생성
2. 서빙 게이트 3조건: 프로덕션 + G7_STATIC_CACHE(기본 on) + 게시 완료(manifest 존재)
3. 폴백 2층: 태그 계층은 파일 단위 file_exists + 파샬 역변환, fetch 계층은 fetchStaticFirst
4. 무효화는 버전 디렉토리 — 포인터(cache_version)가 바뀔 뿐 파일 덮어쓰기가 없다
5. .json/.js/.css 로 끝나는 신규 동적(Laravel) 라우트를 만들지 않는다 — 실파일만 정적 확장자
```
## 1. 왜 게시(bake)인가
부트스트랩 리소스는 병합 결과물이다 — lang 은 코어→템플릿→모듈→플러그인→언어팩 훅, routes 는 템플릿+활성 모듈, 번들은 활성 확장 IIFE concat. 병합 구조는 유지하되 그 **결과물**을 실파일로 게시하면, PHP lifecycle 없이 웹서버가 직접 서빙한다 (실측: API 경유 웜 ~131ms vs 정적 파일 17ms).
두 위험은 다음으로 해소된다.
1. **재게시 트리거 누락 → 조용한 stale**: 모든 수명주기 이벤트(확장 설치/활성/비활성/삭제/업데이트, 언어팩 변경, 레이아웃 편집기 저장, 커스텀 번역, CLI 빌드/캐시클리어)는 `ClearsTemplateCaches::incrementExtensionCacheVersion()` 을 경유한다. 게시 예약을 그 **단일 지점 내부**에 심어 누락이 구조적으로 불가능하다. 추가로 blade 렌더가 현재 버전의 미게시를 감지하면 자가 치유(terminating 게시 예약)한다.
2. **stale 파일 참조**: 덮어쓰기가 아닌 **버전 디렉토리** 게시 + 포인터(`cache_version`)는 blade 가 HTML 에 주입한다. 구버전 파일은 잔존해도 참조되지 않는다. content-hash 가 필요 없다.
## 2. 경로 규약
```text
public/build/ext/{cache_version}/
├── manifest.json ← 마지막에 기록 (게시 완료 마커)
├── .htaccess ← Apache: public, max-age=31536000, immutable
├── templates/{template_id}/
│ ├── lang/{locale}.json ← 병합 결과 raw (lang API 와 동일 페이로드)
│ ├── components.json ← components.json 사본 (raw)
│ ├── routes.json ← 병합 결과 + {"success":true,...} 봉투
│ └── assets/{dist 이하 경로} ← dist/** 사본 (*.map 제외, 허용 확장자만)
└── bundles/
├── modules.js / modules.css ← 확장 병합 번들 사본
└── plugins.js / plugins.css
```
- 대상: **활성** 템플릿 전수, 로케일은 활성 로케일 열거(언어팩이 추가한 로케일 포함).
- 원자성: `{v}.tmp/` 에 전부 쓴 뒤 디렉토리 rename → `{v}/`, `manifest.json` 은 rename 후 마지막 기록. **manifest 존재 = 게시 완료** — 부분 게시 상태가 참조되지 않는다.
- 쓰기 안전: 식별자(vendor-name)·로케일 패턴 화이트리스트, dist 복사는 허용 확장자 화이트리스트(자산 서빙 검증 규칙과 동일 목록) + `*.map` 제외 + realpath 컨테인먼트.
- `config.json` 과 레이아웃 JSON 은 게시 대상이 **아니다** — 전자는 버전 핸드셰이크의 SSoT(항상 신선해야 함), 후자는 인증 문맥(optional.sanctum) 의존.
## 3. 서빙·폴백 모델
- 실파일이므로 Apache(`RewriteCond !-f`)/nginx(`try_files $uri`) 어느 쪽이든 서버 설정 추가 없이 rewrite 전에 직접 서빙된다. 정적 확장자 정규식 location(`location ~* \.(js|css|json)$`)이 있는 서버에서는 그 location 이 곧 서빙 메커니즘이 된다.
- **`.json/.js/.css` 로 끝나는 신규 동적(Laravel) 라우트를 만들지 않는다.** 정적 확장자 location 이 있는 서버에서 PHP 폴백 없이 404 가 되는 함정을 원천 회피한다 (정적 검사가 차단). 404 는 프론트 폴백의 설계된 신호다.
- **서버 렌더(blade) 층**: `AssetUrl::staticExtBase()` 가 게이트 3조건(프로덕션·`core.static_cache.enabled`·게시 완료)을 판정한다. 태그로 방출되는 자산(템플릿 CSS·JS, 번들 4종)은 **그 자산의 개별 `file_exists`** 까지 확인 후에만 정적 URL 을 방출한다 — 태그는 404 를 받아도 스스로 재시도하지 못하기 때문.
- **프론트 fetch 층**: routes/lang/components 는 정적 URL 우선 + 응답 `!ok`/네트워크 실패 시 **즉시** 종전 API URL 폴백. 폴백 발생은 console.warn 1줄로 관측 가능하다 (조용한 폴백 금지 — 자가 치유 실패를 발견할 유일한 통로).
- **태그 계층 런타임 복구**: 브라우저에 캐시된 구 HTML 이 GC 된 구버전 정적 자산을 참조하는 등 서빙 시점 404 는, 자산 URL 자가 복구 파샬이 `/build/ext/{v}/…` → 종전 `/api/…` URL 로 1회 역변환한다. `/build/core/**` 는 실물 정적 파일이라 변환 대상이 아니다. 확장 병합 번들(`ModuleAssetLoader`)도 동일 규칙 — 정적 번들 URL 은 1회만 시도하고 미스 시 종전 API URL 에서 기존 재시도 예산을 이어간다 (같은 정적 URL 재시도는 게시본 소실을 복구하지 못한다).
- **SEO(봇) 렌더는 정적 URL 을 쓰지 않는다**: 봇 HTML 은 `seo.page.*` 캐시(키에 cache_version 미포함, TTL 수시간)에 박제되는데 게시 디렉토리는 GC 대상이고 SEO HTML 에는 자가 복구 파샬이 없다. `AssetUrl::templateAsset(..., allowStatic: false)` 로 무버전 API URL 을 고정한다 — 생성한 URL 이 정적 게시 GC 보다 오래 사는 저장소에 남는 호출부는 모두 이 원칙을 따른다.
- **비프로덕션(dev)에서는 정적 URL 을 방출하지 않는다** — dev 는 파일 수정 즉시 반영이 우선이며, 게시 자체도 트리거되지 않는다.
## 4. 트리거와 수명주기
| 트리거 | 지점 | 방식 |
|---|---|---|
| 수명주기 전체 | `incrementExtensionCacheVersion()` 내부 | terminating 게시 예약 — 프로세스당 1회, **실행 시점의 최종 버전**으로 게시 (연속 bump 자연 병합) |
| 자가 치유 | blade 렌더의 `staticExtBase()` 게이트 | 현재 버전 미게시 감지 시 terminating 게시 예약. 이번 응답은 API URL (첫 방문자 1회만 종전 속도) |
| 수동/워밍 | `php artisan ext-static:publish [--force]` | 설치기 완료 단계에서도 호출 |
| GC | `php artisan ext-static:cleanup` + 게시 성공 직후 인라인 GC | 현재 + 직전 1개 보존. 스케줄 일 1회 등록 |
- 동시성: 게시는 캐시 락으로 단일 실행. manifest 존재 시 skip(멱등).
- 실패 정책: 쓰기 실패는 로그만 남기고 tmp 정리 — 사이트는 API 폴백으로 정상 ("정적 fast path 미적용" 상태이지 장애가 아니다). 다음 렌더의 자가 치유가 재시도한다.
- routes 병합이 열화 상태(확장 업데이트 진행 중 등)면 그 산출물은 게시하지 않는다 — 정적 파일은 스스로 회복되지 않으므로 열화가 다음 bump 까지 박제된다. 같은 규율이 폴백 API 의 HTTP 캐시 헤더에도 적용된다 — 열화 응답에는 `public, max-age` 를 부여하지 않는다 (브라우저/CDN 박제 방지).
## 5. 운영자 kill-switch
`.env` 에 `G7_STATIC_CACHE=false` 를 두면 게시가 중단되고 blade 가 정적 URL 을 방출하지 않아 전면 API 폴백(종전 동작)으로 돌아간다. 기본값은 활성이며 관리자 UI 는 두지 않는다 (내부 인프라 — 파일시스템 상태를 화면 토글이 즉시 반영한다고 오해할 소지가 있고, 문제 상황의 조치는 서버 접근을 전제한다).
## 6. 권한과 소유권 (설치/코어 업데이트)
게시물은 **런타임이 `public/build/ext` 에 쓰는** 최초의 산출물이다 — 종전의 `public/build/core` 는 빌드 도구가 배포 시점에 만들고 런타임은 읽기만 했다. 따라서 다음이 성립해야 정적 fast path 가 동작한다 (미성립 시에도 사이트는 전면 API 폴백으로 정상 — 경고 로그만 남는다).
- **웹 프로세스 계정의 `public/build` 쓰기 권한**: 게시의 정상 주체는 terminating 훅/자가 치유 = php-fpm(웹 계정)이다. 시스템 요구사항의 그룹 공유(방식 A) 구성이라면 `public/build` 도 같은 원칙(g+w + 공용 그룹)을 적용한다. 게시 코드가 디렉토리를 0775 로 생성하므로 umask 동조 환경에서 그룹 쓰기가 유지된다.
- **설치 시**: 설치기 완료 단계가 `ext-static:publish --force` 를 best-effort 로 실행한다 — 설치기는 웹 요청 컨텍스트에서 돌므로 산출물은 웹 계정 소유가 되어 이후 재게시와 자연 정합한다. 실패해도 설치는 완료되고 첫 방문의 자가 치유가 재시도한다.
- **sudo 코어 업데이트 시**: 업데이트가 root 로 실행되면 종료 시점(소유권 복원 `app.update.restore_ownership` **이후**)에 도는 terminating 게시가 root 소유 산출물을 만들 수 있다. 산출물은 게시 트리만이 아니다 — 게시는 캐시 락(`storage/framework/cache/data/xx` 샤드 디렉토리 신설)과 병합 번들 빌드(`storage/app/ext-bundles`)까지 **간접적으로 쓴다**. root 소유 캐시 샤드가 남으면 이후 웹 프로세스의 캐시 쓰기가 그 샤드에 해시되는 순간 Permission denied 로 죽어 전면 500 이 된다(7.0.10 업그레이드 실사례). 따라서 **root 프로세스에서는 terminating 자동 게시를 예약하지 않고** 다음 웹 렌더의 자가 치유(웹 계정)에 위임한다. 명시적 `ext-static:publish` 는 root 로도 실행 가능하며 게시 트리(`public/build/ext`)는 부모 소유권 상속으로 정상화되지만, storage 측 간접 산출물의 소유권은 운영자 책임이다 — sudo 로 수동 게시했다면 `storage`/`bootstrap/cache` 의 그룹 쓰기(`chgrp -R {웹그룹}` + `chmod -R g+w`)를 확인한다.
- **코어 업데이트의 orphan 정리**: 게시본은 릴리즈 소스에 없는 로컬 파생물이므로 `app.update.excludes` 에 `build/ext` 로 등록되어 있다 — `--prune` 업데이트가 orphan 으로 삭제하지 않고, 백업 대상에서도 제외된다.
권한 문제로 게시가 계속 실패하는 환경(공유 호스팅 등)에서는 `G7_STATIC_CACHE=false` 로 기능을 끄면 경고 로그도 남지 않는다.
## 7. 다중 웹서버 제약
게시물은 **로컬 디스크** 파생물이다 (확장 병합 번들 캐시와 동일한 제약). 다중 웹서버 스케일아웃 구성에서는 서버마다 게시가 필요하다 — terminating 트리거는 요청을 받은 서버에서만 실행되므로, 나머지 서버는 각자의 blade 자가 치유가 첫 렌더에서 보충한다. 공유 스토리지에 `public/build/ext` 를 올리는 구성은 rename 원자성이 보장되는 파일시스템에서만 사용한다.
## 8. nginx 권장 설정 (선택)
버전 디렉토리라 내용이 불변이므로, 서버 기본 재검증(ETag/Last-Modified)만으로도 충분하다. 다만 **압축은 서버 몫이다** — 정적 서빙은 Laravel 의 응답 압축(GzipEncodeResponse)을 우회하므로, 서버에 gzip 설정이 없으면 종전 API 대비 전송량이 회귀한다 (실측: 병합 lang JSON 약 525KB 비압축). 권장:
```nginx
location ^~ /build/ext/ {
expires max;
add_header Cache-Control "public, immutable";
access_log off;
gzip on;
gzip_types application/json application/javascript text/css image/svg+xml;
gzip_min_length 1024;
}
```
Apache 는 게시 트리에 포함된 `.htaccess` 가 같은 캐시 헤더와 `mod_deflate` 압축을 함께 선언한다 (모듈 미탑재 시 자동 무시).
## 9. 관련 규율
- 정적 우선 URL 규칙은 서버측 `AssetUrl` 과 프론트측 `assetUrl.ts`(+ 자가 복구 파샬 역변환)가 **항상 쌍으로** 수정되어야 한다 — 한쪽만 바꾸면 그 자산만 404 가 된다.
- 게시 산출물(`public/build/ext/`)은 git 미추적·release 페이로드 제외다. `public/build/core/` 는 계속 추적한다 (배포 산출물) — 혼동 금지.
- 루트 `npm run build`(기본 vite 앱 빌드)는 `public/build` 를 비운다(`emptyOutDir`) — `core` 와 `ext` 가 함께 지워진다. `core` 는 `core:build --production` 으로 재생성해야 하고(공개 #70 의 실제 원인), `ext` 는 다음 프로덕션 렌더의 자가 치유가 재게시한다.
- 확장 병합 번들의 생성 규율은 [module-assets.md "서버측 번들 병합"](../extension/module-assets.md) 이 소유한다. 본 게시는 그 산출물의 **사본**만 만든다.
+1
View File
@@ -10,6 +10,7 @@
3. 접두사로 캐시 키 격리: g7:core:{key}, g7:module.{id}:{key}, g7:plugin.{id}:{key} 3. 접두사로 캐시 키 격리: g7:core:{key}, g7:module.{id}:{key}, g7:plugin.{id}:{key}
4. TTL은 g7_core_settings('cache.*')로 중앙 관리 (하드코딩 금지) 4. TTL은 g7_core_settings('cache.*')로 중앙 관리 (하드코딩 금지)
5. Service 생성자에서 CacheInterface 타입힌트하면 자동 주입 5. Service 생성자에서 CacheInterface 타입힌트하면 자동 주입
6. 쓰기 실패는 fail-soft — put/forget/putMany 는 false, remember 는 콜백 결과 반환 + 경고 로그 (캐시는 최적화라 페이지를 죽이지 않는다)
``` ```
--- ---
+2
View File
@@ -449,6 +449,8 @@ GET /api/modules/assets/{identifier}/{path}
활성 모듈/플러그인이 늘어날수록 개별 IIFE JS/CSS 요청이 선형 증가한다. 이를 줄이기 위해 코어는 타입별(모듈/플러그인)로 활성 `global` 에셋을 서버에서 하나의 번들로 병합해 서빙한다. 각 확장 IIFE 는 자체 클로저에서 자가등록(레지스트리 + 핸들러/리스너)을 수행하므로, priority 순으로 이어붙여 단일 `<script>` 로 실행해도 등록 동작은 동일하다. 활성 모듈/플러그인이 늘어날수록 개별 IIFE JS/CSS 요청이 선형 증가한다. 이를 줄이기 위해 코어는 타입별(모듈/플러그인)로 활성 `global` 에셋을 서버에서 하나의 번들로 병합해 서빙한다. 각 확장 IIFE 는 자체 클로저에서 자가등록(레지스트리 + 핸들러/리스너)을 수행하므로, priority 순으로 이어붙여 단일 `<script>` 로 실행해도 등록 동작은 동일하다.
> 프로덕션에서는 이 병합 산출물의 사본이 정적 게시본(`public/build/ext/{v}/bundles/`)으로 함께 게시되어 웹서버가 직접 서빙할 수 있다 — [static-asset-publishing.md](../backend/static-asset-publishing.md) 참조. 번들의 생성·정렬·구분자 규율은 계속 본 문서가 소유한다.
### 서빙 엔드포인트 ### 서빙 엔드포인트
``` ```
+3 -3
View File
@@ -138,8 +138,8 @@ stat -c '%a %U:%G' storage
```bash ```bash
# 인스톨러 완료 후 운영자가 1회 실행 # 인스톨러 완료 후 운영자가 1회 실행
sudo chown -R $USER:www-data storage bootstrap/cache vendor modules plugins templates sudo chown -R $USER:www-data storage bootstrap/cache vendor modules plugins templates public/build
sudo chmod -R 775 storage bootstrap/cache vendor modules plugins templates sudo chmod -R 775 storage bootstrap/cache vendor modules plugins templates public/build
``` ```
추가로 php-fpm / systemd 의 umask 를 `002` 로 설정하면 cron·composer·수동 SSH artisan 등 외부 프로세스도 동일 권한으로 파일을 만든다. 추가로 php-fpm / systemd 의 umask 를 `002` 로 설정하면 cron·composer·수동 SSH artisan 등 외부 프로세스도 동일 권한으로 파일을 만든다.
@@ -154,7 +154,7 @@ sudo chmod -R 775 storage bootstrap/cache vendor modules plugins templates
**방식 B/C (단일 소유자)**: **방식 B/C (단일 소유자)**:
```bash ```bash
sudo chmod -R 755 storage bootstrap/cache vendor modules plugins templates sudo chmod -R 755 storage bootstrap/cache vendor modules plugins templates public/build
``` ```
그룹 쓰기 비트가 없으므로 코어 자동 umask 동조는 발동하지 않는다 (운영자 의도 존중). 추가 설정 불필요. 그룹 쓰기 비트가 없으므로 코어 자동 umask 동조는 발동하지 않는다 (운영자 의도 존중). 추가 설정 불필요.
File diff suppressed because one or more lines are too long
+24
View File
@@ -1576,6 +1576,27 @@ if (! function_exists('optimizeConfigCacheSSE')) {
} }
} }
if (! function_exists('publishStaticCacheSSE')) {
/**
* 설치 완료 직후 부트스트랩 리소스 정적 게시(bake)를 최초 수행한다 (#122).
*
* 이 지점이 없으면 첫 방문자가 blade 자가 치유(1회 API 폴백)를 겪는다.
* best_effort — 실패해도 설치는 완료 처리한다(사이트는 API 폴백으로 정상).
*
* @return array 태스크 실행 결과
*/
function publishStaticCacheSSE(): array
{
return executeArtisanCommandSSE(
artisanCommand: 'ext-static:publish --force',
taskId: 'static_publish',
taskNameKey: 'task_static_publish',
successMsgKey: 'log_static_publish_success',
errorMsgKey: 'error_static_publish_failed'
);
}
}
if (! function_exists('createSettingsJsonSSE')) { if (! function_exists('createSettingsJsonSSE')) {
function createSettingsJsonSSE(): array function createSettingsJsonSSE(): array
{ {
@@ -1859,6 +1880,9 @@ if (! function_exists('runInstallationTasks')) {
// complete_flag(installer_completed=true) 이후에 config 캐시를 최초 생성해야 // complete_flag(installer_completed=true) 이후에 config 캐시를 최초 생성해야
// 설치가 config:cache 켜진 상태로 시작한다. best_effort — 실패해도 설치는 완료. // 설치가 config:cache 켜진 상태로 시작한다. best_effort — 실패해도 설치는 완료.
$tasks[] = ['id' => 'config_cache', 'function' => 'optimizeConfigCacheSSE', 'best_effort' => true]; $tasks[] = ['id' => 'config_cache', 'function' => 'optimizeConfigCacheSSE', 'best_effort' => true];
// 부트스트랩 리소스 정적 게시(bake) 최초 수행 — 없으면 첫 방문자가
// 자가 치유(1회 API 폴백)를 겪는다. best_effort — 실패해도 설치는 완료.
$tasks[] = ['id' => 'static_publish', 'function' => 'publishStaticCacheSSE', 'best_effort' => true];
foreach ($tasks as $task) { foreach ($tasks as $task) {
if (checkAbortStatusSSE()) { if (checkAbortStatusSSE()) {
+3
View File
@@ -280,6 +280,7 @@ return [
'task_create_settings_json' => 'Creating Settings Files', 'task_create_settings_json' => 'Creating Settings Files',
'task_complete_flag' => 'Finalizing Installation', 'task_complete_flag' => 'Finalizing Installation',
'task_config_cache' => 'Building Configuration Cache', 'task_config_cache' => 'Building Configuration Cache',
'task_static_publish' => 'Publishing Static Bootstrap Resources',
'task_unknown' => 'Unknown Task', 'task_unknown' => 'Unknown Task',
// Task Group Names // Task Group Names
@@ -382,10 +383,12 @@ return [
// Error Messages - Worker (Cache) // Error Messages - Worker (Cache)
'error_cache_clear_failed' => 'Cache clearing failed', 'error_cache_clear_failed' => 'Cache clearing failed',
'error_config_cache_failed' => 'Configuration cache build failed', 'error_config_cache_failed' => 'Configuration cache build failed',
'error_static_publish_failed' => 'Static bootstrap resource publishing failed (site still works via API fallback)',
// Log Messages - Worker (Cache) // Log Messages - Worker (Cache)
'log_cache_clear_success' => 'Cache clearing completed', 'log_cache_clear_success' => 'Cache clearing completed',
'log_config_cache_success' => 'Configuration cache built', 'log_config_cache_success' => 'Configuration cache built',
'log_static_publish_success' => 'Static bootstrap resources published',
// Error Messages - Worker (Settings JSON) // Error Messages - Worker (Settings JSON)
'error_settings_json_failed' => 'Settings file creation failed', 'error_settings_json_failed' => 'Settings file creation failed',
+3
View File
@@ -280,6 +280,7 @@ return [
'task_create_settings_json' => '설정 파일 생성', 'task_create_settings_json' => '설정 파일 생성',
'task_complete_flag' => '설치 완료 처리', 'task_complete_flag' => '설치 완료 처리',
'task_config_cache' => '설정 캐시 생성', 'task_config_cache' => '설정 캐시 생성',
'task_static_publish' => '부트스트랩 리소스 정적 게시',
'task_unknown' => '알 수 없는 작업', 'task_unknown' => '알 수 없는 작업',
// 작업 그룹명 // 작업 그룹명
@@ -382,10 +383,12 @@ return [
// 에러 메시지 - Worker (Cache) // 에러 메시지 - Worker (Cache)
'error_cache_clear_failed' => '캐시 클리어에 실패했습니다', 'error_cache_clear_failed' => '캐시 클리어에 실패했습니다',
'error_config_cache_failed' => '설정 캐시 생성에 실패했습니다', 'error_config_cache_failed' => '설정 캐시 생성에 실패했습니다',
'error_static_publish_failed' => '부트스트랩 리소스 정적 게시에 실패했습니다 (사이트는 API 경로로 정상 동작합니다)',
// 로그 메시지 - Worker (Cache) // 로그 메시지 - Worker (Cache)
'log_cache_clear_success' => '캐시 클리어 완료', 'log_cache_clear_success' => '캐시 클리어 완료',
'log_config_cache_success' => '설정 캐시 생성 완료', 'log_config_cache_success' => '설정 캐시 생성 완료',
'log_static_publish_success' => '부트스트랩 리소스 정적 게시 완료',
// 에러 메시지 - Worker (Settings JSON) // 에러 메시지 - Worker (Settings JSON)
'error_settings_json_failed' => '설정 파일 생성에 실패했습니다', 'error_settings_json_failed' => '설정 파일 생성에 실패했습니다',
+25 -9
View File
@@ -31,8 +31,9 @@ import { createLogger, Logger } from './utils/Logger';
import { webSocketManager } from './websocket/WebSocketManager'; import { webSocketManager } from './websocket/WebSocketManager';
import { getModuleAssetLoader, parseModuleAssetsFromConfig, parsePluginAssetsFromConfig, parseBundleUrlsFromConfig } from './modules'; import { getModuleAssetLoader, parseModuleAssetsFromConfig, parsePluginAssetsFromConfig, parseBundleUrlsFromConfig } from './modules';
import { SystemBannerManager } from './template-engine/SystemBannerManager'; import { SystemBannerManager } from './template-engine/SystemBannerManager';
import { fetchWithRetry, installUnloadGuard, isDocumentUnloading } from './template-engine/networkResilience'; import { installUnloadGuard, isDocumentUnloading } from './template-engine/networkResilience';
import { suffixed } from './support/assetUrl'; import { suffixed, extStaticUrl } from './support/assetUrl';
import { fetchStaticFirst } from './support/fetchStaticFirst';
import { resetLocalInitTracking } from './template-engine/localInitSlot'; import { resetLocalInitTracking } from './template-engine/localInitSlot';
/** /**
* DevTools 추적 - G7DevToolsCore.getInstance() 직접 호출 대신 G7Core.devTools를 사용합니다. * DevTools 추적 - G7DevToolsCore.getInstance() 직접 호출 대신 G7Core.devTools를 사용합니다.
@@ -456,8 +457,13 @@ export class TemplateApp {
const componentRegistry = ComponentRegistry.getInstance(); const componentRegistry = ComponentRegistry.getInstance();
const authManager = AuthManager.getInstance(); const authManager = AuthManager.getInstance();
// 저장된 캐시 버전 로드 (초기 API 호출에 사용) // 캐시 버전 시드 — blade 주입값(현재 렌더와 동일 버전) 우선, 부재 시 localStorage 폴백.
const storedCacheVersion = this.loadCacheVersionFromStorage() || 0; // localStorage 만 보면 stale 버전으로 첫 burst 가 나가고 config 핸드셰이크가
// routes + lang 을 통째로 재로드하는 이중 로드가 발생한다 (#122, @since engine-v1.61.0)
const injectedCacheVersion =
typeof window !== 'undefined' ? Number((window as any).G7Config?.cache_version) || 0 : 0;
const storedCacheVersion =
injectedCacheVersion > 0 ? injectedCacheVersion : this.loadCacheVersionFromStorage() || 0;
const [_, __, routesData, ___, templateConfig] = await Promise.all([ const [_, __, routesData, ___, templateConfig] = await Promise.all([
// 템플릿 엔진 초기화 (다국어 파일 병렬 로드) // 템플릿 엔진 초기화 (다국어 파일 병렬 로드)
@@ -471,11 +477,16 @@ export class TemplateApp {
// ComponentRegistry 로딩 (components.json) // ComponentRegistry 로딩 (components.json)
componentRegistry.loadComponents( componentRegistry.loadComponents(
this.config.templateId, this.config.templateId,
this.config.templateType this.config.templateType,
storedCacheVersion
), ),
// routes.json 로딩 (저장된 캐시 버전 사용) // routes.json 로딩 (저장된 캐시 버전 사용)
// 네트워크 일시 실패(응답 없음)에만 재시도한다. HTTP 에러는 아래 체인이 종전대로 throw. // 정적 게시본(bake) 우선 — miss 면 즉시 종전 API 로 폴백 (#122).
fetchWithRetry( // legacy 측은 네트워크 일시 실패(응답 없음)에만 재시도. HTTP 에러는 아래 체인이 종전대로 throw.
fetchStaticFirst(
storedCacheVersion > 0
? extStaticUrl(`templates/${this.config.templateId}/routes.json`, storedCacheVersion)
: null,
suffixed(`/api/templates/${this.config.templateId}/routes`, 'json', storedCacheVersion > 0 ? storedCacheVersion : null), suffixed(`/api/templates/${this.config.templateId}/routes`, 'json', storedCacheVersion > 0 ? storedCacheVersion : null),
{ label: 'routes.json' } { label: 'routes.json' }
) )
@@ -526,7 +537,9 @@ export class TemplateApp {
// 확장 기능 캐시 버전 저장 (모듈/플러그인 활성화 시 갱신됨) // 확장 기능 캐시 버전 저장 (모듈/플러그인 활성화 시 갱신됨)
if (templateConfig?.cache_version !== undefined) { if (templateConfig?.cache_version !== undefined) {
const previousVersion = this.loadCacheVersionFromStorage(); // 재로드 판정 기준은 "이번 burst 가 실제 사용한 버전" — stale localStorage 와
// 비교하면 blade 시드로 이미 최신 URL 을 쓴 경우에도 재로드가 발화한다 (#122)
const previousVersion = storedCacheVersion > 0 ? storedCacheVersion : null;
this.extensionCacheVersion = templateConfig.cache_version; this.extensionCacheVersion = templateConfig.cache_version;
this.saveCacheVersionToStorage(this.extensionCacheVersion); this.saveCacheVersionToStorage(this.extensionCacheVersion);
logger.log('Extension cache version:', this.extensionCacheVersion); logger.log('Extension cache version:', this.extensionCacheVersion);
@@ -535,7 +548,10 @@ export class TemplateApp {
if (previousVersion !== null && previousVersion !== this.extensionCacheVersion) { if (previousVersion !== null && previousVersion !== this.extensionCacheVersion) {
logger.log('Cache version changed, reloading routes...'); logger.log('Cache version changed, reloading routes...');
// routes.json을 새 캐시 버전으로 다시 로드 // routes.json을 새 캐시 버전으로 다시 로드
const newRoutesData = await fetchWithRetry( // 재로드는 **새 버전** 정적 경로를 조합한다 — 아직 미게시면 404 →
// fetchStaticFirst 가 legacy 로 즉시 폴백 (#122)
const newRoutesData = await fetchStaticFirst(
extStaticUrl(`templates/${this.config.templateId}/routes.json`, this.extensionCacheVersion),
suffixed(`/api/templates/${this.config.templateId}/routes`, 'json', this.extensionCacheVersion), suffixed(`/api/templates/${this.config.templateId}/routes`, 'json', this.extensionCacheVersion),
{ label: 'routes.json (reload)' } { label: 'routes.json (reload)' }
) )
@@ -0,0 +1,255 @@
/**
* TemplateApp 캐시 버전 시드 테스트 (#122 이중 로드 제거)
*
* blade 는 이미 `window.G7Config.cache_version` 을 주입하는데 TemplateApp 이
* localStorage 만 읽어, stale localStorage 를 가진 재방문자의 첫 burst 가
* 구버전 `?v` 로 나가고 config 핸드셰이크가 routes + lang(ko·en, `_=` 버스터)을
* 통째로 다시 내려받던 결함의 회귀를 막는다 (~500KB 중복, 부트 ~1.3s 연장).
*
* 검증 대상:
* - A-1: 시드 우선순위 — blade 주입값 > localStorage
* - A-2: 핸드셰이크 가드 기준 — "이번 burst 가 실제 사용한 버전" 과 비교
* - 기존 시맨틱 보존: 주입 부재 시 localStorage 폴백, 렌더~부트 사이 bump 복구
*
* @effects no_duplicate_boot_requests, handshake_reload_preserved_on_version_mismatch
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { TemplateApp } from '../TemplateApp';
import type { TemplateAppConfig } from '../TemplateApp';
import { initTemplateEngine } from '../template-engine';
import { ComponentRegistry } from '../template-engine/ComponentRegistry';
const mockApiClient = {
post: vi.fn().mockResolvedValue({}),
get: vi.fn().mockResolvedValue({}),
removeToken: vi.fn(),
setToken: vi.fn(),
getToken: vi.fn().mockReturnValue(null),
setOnUnauthorized: vi.fn(),
};
vi.mock('../api/ApiClient', () => ({
getApiClient: () => mockApiClient,
}));
const { sharedActionDispatcher } = vi.hoisted(() => ({
sharedActionDispatcher: {
setNavigate: vi.fn(),
setGlobalState: vi.fn(),
setDefaultContext: vi.fn(),
setGlobalStateUpdater: vi.fn(),
registerHandler: vi.fn(),
createHandler: vi.fn(() => vi.fn()),
customHandlers: new Map<string, unknown>(),
},
}));
vi.mock('../template-engine', () => ({
initTemplateEngine: vi.fn().mockResolvedValue(undefined),
renderTemplate: vi.fn().mockResolvedValue(undefined),
destroyTemplate: vi.fn(),
updateTemplateData: vi.fn(),
getActionDispatcher: vi.fn().mockReturnValue(sharedActionDispatcher),
getState: vi.fn().mockReturnValue({
actionDispatcher: sharedActionDispatcher,
reactRoot: null,
currentLayoutJson: null,
}),
}));
vi.mock('../routing/Router', () => ({
Router: vi.fn(function (this: any) {
this.loadRoutes = vi.fn().mockResolvedValue(undefined);
this.setRoutes = vi.fn();
this.on = vi.fn();
this.navigateToCurrentPath = vi.fn();
this.getRoutes = vi.fn().mockReturnValue([]);
}),
}));
vi.mock('../template-engine/ComponentRegistry', () => {
const mockInstance = {
loadComponents: vi.fn().mockResolvedValue(undefined),
getComponent: vi.fn().mockReturnValue(() => null),
hasComponent: vi.fn().mockReturnValue(true),
getInstance: vi.fn(),
};
mockInstance.getInstance.mockReturnValue(mockInstance);
return {
ComponentRegistry: { getInstance: vi.fn(() => mockInstance) },
};
});
/** routes.json 정상 응답 */
function routesOk(): Response {
return {
ok: true,
status: 200,
json: async () => ({ success: true, data: { routes: [] } }),
} as unknown as Response;
}
/** config.json 정상 응답 (cache_version 포함) */
function configOk(cacheVersion?: number): Response {
return {
ok: true,
status: 200,
json: async () => ({
success: true,
data: cacheVersion !== undefined ? { cache_version: cacheVersion } : {},
}),
} as unknown as Response;
}
/** lang 응답 (사전 raw JSON) */
function langOk(): Response {
return {
ok: true,
status: 200,
json: async () => ({}),
} as unknown as Response;
}
function makeConfig(): TemplateAppConfig {
return {
templateId: 'sirsoft-basic',
templateType: 'user',
locale: 'ko',
debug: false,
};
}
/**
* fetch 스텁 — URL 종류별 정상 응답을 돌려주고 호출 URL 을 기록한다.
*/
function stubFetch(configVersion?: number): ReturnType<typeof vi.fn> {
const fetchMock = vi.fn().mockImplementation((url: string) => {
const u = String(url);
if (u.includes('/routes')) return Promise.resolve(routesOk());
if (u.includes('/config')) return Promise.resolve(configOk(configVersion));
if (u.includes('/lang/')) return Promise.resolve(langOk());
return Promise.resolve(configOk());
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
function callsMatching(fetchMock: ReturnType<typeof vi.fn>, needle: string): string[] {
return fetchMock.mock.calls
.map((c: any[]) => String(c[0]))
.filter((u: string) => u.includes(needle));
}
describe('TemplateApp 캐시 버전 시드 (#122)', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>';
vi.clearAllMocks();
localStorage.clear();
(window as any).G7Config = {};
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
localStorage.clear();
delete (window as any).G7Config;
});
it('blade 주입값이 stale localStorage 보다 우선한다 — 첫 routes 요청이 주입 버전으로 나간다', async () => {
(window as any).G7Config = { cache_version: 7 };
localStorage.setItem('g7_cache_version', '3');
const fetchMock = stubFetch(7);
const app = new TemplateApp(makeConfig());
await app.init();
const routesCalls = callsMatching(fetchMock, '/routes');
expect(routesCalls.length).toBeGreaterThanOrEqual(1);
expect(routesCalls[0]).toContain('v=7');
expect(routesCalls[0]).not.toContain('v=3');
});
it('주입 버전과 config 응답 버전이 일치하면 routes 재로드도 `_=` 버스터 재로드도 없다', async () => {
(window as any).G7Config = { cache_version: 7 };
localStorage.setItem('g7_cache_version', '3');
const fetchMock = stubFetch(7);
const app = new TemplateApp(makeConfig());
await app.init();
// 이중 로드 부재 — routes 는 정확히 1회
expect(callsMatching(fetchMock, '/routes')).toHaveLength(1);
// `_=` 캐시 버스터 재로드 0건 (lang ko·en 재다운로드 부재)
const busterCalls = fetchMock.mock.calls
.map((c: any[]) => String(c[0]))
.filter((u: string) => /[?&]_=/.test(u));
expect(busterCalls).toHaveLength(0);
// localStorage 는 config 응답으로 치유된다
expect(localStorage.getItem('g7_cache_version')).toBe('7');
});
it('렌더~부트 사이 bump(주입 7 → config 9)는 핸드셰이크로 여전히 복구된다 (기존 시맨틱 보존)', async () => {
(window as any).G7Config = { cache_version: 7 };
localStorage.setItem('g7_cache_version', '3');
const fetchMock = stubFetch(9);
const app = new TemplateApp(makeConfig());
await app.init();
// 첫 burst 는 v=7, 핸드셰이크 재로드는 v=9 — 정확히 2회
const routesCalls = callsMatching(fetchMock, '/routes');
expect(routesCalls).toHaveLength(2);
expect(routesCalls[0]).toContain('v=7');
expect(routesCalls[1]).toContain('v=9');
expect(localStorage.getItem('g7_cache_version')).toBe('9');
});
it('blade 주입이 없으면 localStorage 로 폴백한다 (기존 시맨틱 보존)', async () => {
(window as any).G7Config = {};
localStorage.setItem('g7_cache_version', '5');
const fetchMock = stubFetch(5);
const app = new TemplateApp(makeConfig());
await app.init();
const routesCalls = callsMatching(fetchMock, '/routes');
expect(routesCalls).toHaveLength(1);
expect(routesCalls[0]).toContain('v=5');
});
it('최초 방문(주입 0/부재 + localStorage 없음)은 무버전 요청 + 재로드 없음 (기존 시맨틱 보존)', async () => {
(window as any).G7Config = {};
const fetchMock = stubFetch(7);
const app = new TemplateApp(makeConfig());
await app.init();
// 무버전 첫 요청 1회, previousVersion=null 이므로 config 버전 수신 후에도 재로드 없음
const routesCalls = callsMatching(fetchMock, '/routes');
expect(routesCalls).toHaveLength(1);
expect(routesCalls[0]).not.toContain('v=');
// 수신한 버전은 저장된다
expect(localStorage.getItem('g7_cache_version')).toBe('7');
});
it('initTemplateEngine 과 loadComponents 에 같은 시드 버전이 전달된다', async () => {
(window as any).G7Config = { cache_version: 7 };
localStorage.setItem('g7_cache_version', '3');
stubFetch(7);
const app = new TemplateApp(makeConfig());
await app.init();
expect(vi.mocked(initTemplateEngine)).toHaveBeenCalledWith(
expect.objectContaining({ cacheVersion: 7 })
);
const registry = ComponentRegistry.getInstance() as any;
expect(registry.loadComponents).toHaveBeenCalledWith('sirsoft-basic', 'user', 7);
});
});
+32 -2
View File
@@ -8,7 +8,7 @@
import { createLogger } from '../utils/Logger'; import { createLogger } from '../utils/Logger';
import { loadScriptWithRetry } from '../template-engine/networkResilience'; import { loadScriptWithRetry } from '../template-engine/networkResilience';
import { convertToCurrentMode } from '../support/assetUrl'; import { convertToCurrentMode, staticToLegacy } from '../support/assetUrl';
const logger = createLogger('ModuleAssetLoader'); const logger = createLogger('ModuleAssetLoader');
@@ -204,6 +204,18 @@ export class ModuleAssetLoader {
}; };
link.onerror = () => { link.onerror = () => {
// 정적 게시(bake) URL 미스 → 종전 API URL 로 1회 전환 (#122).
// 게시 디렉토리는 GC(현재+직전 1개 보존) 대상이라, 캐시된 HTML 의 구버전
// 정적 URL 이 404 가 될 수 있다 — fetchStaticFirst 와 동일한 즉시 폴백.
const legacy = staticToLegacy(link.getAttribute('href') ?? url);
if (legacy !== null && link.dataset.g7StaticFallback !== '1') {
link.dataset.g7StaticFallback = '1';
logger.warn(`Bundle CSS static miss, falling back to API: ${key} (${url} -> ${legacy})`);
link.href = convertToCurrentMode(legacy);
return;
}
logger.warn(`Failed to load bundle CSS: ${key} (${url})`); logger.warn(`Failed to load bundle CSS: ${key} (${url})`);
resolve(); resolve();
}; };
@@ -240,7 +252,25 @@ export class ModuleAssetLoader {
return existingPromise; return existingPromise;
} }
const loadPromise = loadScriptWithRetry(url, { id: elementId }, { label: `bundle JS: ${key}` }) // 정적 게시(bake) URL 이면 1회만 시도하고, 미스 시 종전 API URL 로 전환해 기존
// 재시도 예산을 이어간다 (#122 — fetchStaticFirst 와 동형: 정적 1 + 레거시 재시도).
// 게시 디렉토리는 GC(현재+직전 1개 보존) 대상이라 캐시된 HTML 의 구버전 정적
// URL 이 404 가 될 수 있고, 같은 정적 URL 재시도는 그 상태를 복구하지 못한다.
const legacyUrl = staticToLegacy(url);
const attempt = legacyUrl !== null
? loadScriptWithRetry(url, { id: elementId }, { label: `bundle JS: ${key}`, retries: 0 })
.catch(() => {
logger.warn(`Bundle JS static miss, falling back to API: ${key} (${url} -> ${legacyUrl})`);
return loadScriptWithRetry(
convertToCurrentMode(legacyUrl),
{ id: elementId },
{ label: `bundle JS: ${key}` }
);
})
: loadScriptWithRetry(url, { id: elementId }, { label: `bundle JS: ${key}` });
const loadPromise = attempt
.then(() => { .then(() => {
logger.log(`Bundle JS loaded: ${key}`); logger.log(`Bundle JS loaded: ${key}`);
const script = document.getElementById(elementId); const script = document.getElementById(elementId);
@@ -22,6 +22,84 @@ describe('ModuleAssetLoader', () => {
.forEach(el => el.remove()); .forEach(el => el.remove());
}); });
describe('loadBundle 정적 게시 폴백 (#122)', () => {
/**
* @effects bundle_script_static_miss_falls_back_to_api
*/
it('정적 번들 JS 실패 시 레거시 API URL 로 전환한다', async () => {
const requested: string[] = [];
const appendSpy = vi.spyOn(document.head, 'appendChild').mockImplementation(((node: any) => {
if (node.tagName === 'SCRIPT') {
requested.push(node.getAttribute('src'));
if (requested.length === 1) {
// 정적 fast path 미스 (게시 디렉토리 GC / 파일 소실)
queueMicrotask(() => node.onerror?.(new Event('error')));
} else {
queueMicrotask(() => node.onload?.());
}
}
return node;
}) as any);
await loader.loadBundle('module', '/build/ext/1787637589/bundles/modules.js', null);
expect(requested).toEqual([
'/build/ext/1787637589/bundles/modules.js',
'/api/modules/bundle.js?v=1787637589',
]);
appendSpy.mockRestore();
});
it('정적 번들 CSS 실패 시 레거시 API URL 로 전환한다', async () => {
const hrefs: string[] = [];
let appendedLink: any = null;
const appendSpy = vi.spyOn(document.head, 'appendChild').mockImplementation(((node: any) => {
if (node.tagName === 'LINK') {
appendedLink = node;
hrefs.push(node.getAttribute('href'));
// 정적 fast path 미스 — 1회차 onerror
queueMicrotask(() => node.onerror?.(new Event('error')));
}
return node;
}) as any);
const done = loader.loadBundle('module', null, '/build/ext/1787637589/bundles/modules.css');
// 1회차 onerror 처리(마이크로태스크) 후 — 같은 link 의 href 가 레거시로 교체돼야 한다
await Promise.resolve();
await Promise.resolve();
expect(hrefs[0]).toBe('/build/ext/1787637589/bundles/modules.css');
expect(appendedLink?.getAttribute('href')).toBe('/api/modules/bundle.css?v=1787637589');
// 레거시 로드 성공 → resolve
appendedLink?.onload?.(new Event('load'));
await done;
appendSpy.mockRestore();
});
it('정적 경로가 아닌 번들 URL 은 종전 재시도 계약 그대로다', async () => {
const requested: string[] = [];
const appendSpy = vi.spyOn(document.head, 'appendChild').mockImplementation(((node: any) => {
if (node.tagName === 'SCRIPT') {
requested.push(node.getAttribute('src'));
queueMicrotask(() => node.onload?.());
}
return node;
}) as any);
await loader.loadBundle('plugin', '/api/plugins/bundle.js?v=7', null);
expect(requested).toEqual(['/api/plugins/bundle.js?v=7']);
appendSpy.mockRestore();
});
});
describe('loadActiveExtensionAssets', () => { describe('loadActiveExtensionAssets', () => {
it('JS 에셋을 priority 오름차순으로 DOM에 append한다 (실행 순서 보장)', async () => { it('JS 에셋을 priority 오름차순으로 DOM에 append한다 (실행 순서 보장)', async () => {
const appendOrder: string[] = []; const appendOrder: string[] = [];
@@ -95,19 +95,24 @@ describe('assetUrl (프론트 자산 URL 빌더)', () => {
); );
}); });
it('ComponentRegistry.ts:250 components.json', () => { it('ComponentRegistry loadManifest components.json (버전 있음/없음 — #122 작업 B)', () => {
// 편집기 경로(버전 미전달)는 무버전 URL 유지 (서버가 현재 버전 폴백 — #588)
expect(suffixed('/api/templates/sirsoft-basic/components', 'json')).toBe( expect(suffixed('/api/templates/sirsoft-basic/components', 'json')).toBe(
'/api/templates/sirsoft-basic/components.json', '/api/templates/sirsoft-basic/components.json',
); );
// 런타임 경로는 캐시 버전 부착 (stale 매니페스트 방지)
expect(suffixed('/api/templates/sirsoft-basic/components', 'json', 7)).toBe(
'/api/templates/sirsoft-basic/components.json?v=7',
);
}); });
it('TemplateApp.ts:2810 config.json + 캐시버스트 쿼리', () => { it('TemplateApp config.json + 캐시버스트 쿼리', () => {
expect(suffixed('/api/templates/sirsoft-basic/config', 'json', null, '_=1699999999')).toBe( expect(suffixed('/api/templates/sirsoft-basic/config', 'json', null, '_=1699999999')).toBe(
'/api/templates/sirsoft-basic/config.json?_=1699999999', '/api/templates/sirsoft-basic/config.json?_=1699999999',
); );
}); });
it('LayoutLoader.ts:750,752 레이아웃 / 미리보기', () => { it('LayoutLoader 레이아웃 / 미리보기', () => {
expect(layoutUrl('sirsoft-basic', 'home', 7)).toBe('/api/layouts/sirsoft-basic/home.json?v=7'); expect(layoutUrl('sirsoft-basic', 'home', 7)).toBe('/api/layouts/sirsoft-basic/home.json?v=7');
expect(layoutPreviewUrl('abc-def')).toBe('/api/layouts/preview/abc-def.json'); expect(layoutPreviewUrl('abc-def')).toBe('/api/layouts/preview/abc-def.json');
}); });
@@ -20,6 +20,7 @@ import {
getAssetUrlMode, getAssetUrlMode,
setAssetUrlMode, setAssetUrlMode,
convertToCurrentMode, convertToCurrentMode,
staticToLegacy,
} from '../assetUrl'; } from '../assetUrl';
/** /**
@@ -160,6 +161,147 @@ describe('자산 URL 자가 복구 불변식 (§12)', () => {
}); });
}); });
describe('정적 게시 역변환 — blade 인라인 복구기와 TS 구현 동등성 (#122 F15)', () => {
/**
* blade 파샬의 인라인 `staticToLegacy` 를 추출해 TS `staticToLegacy` 와 대조한다.
* 역변환 규칙은 서버측 `AssetUrl` 의 정적 게시 트리 규약과 1:1 이어야 하며,
* 두 사본이 갈라지면 태그 계층의 서빙 시점 404 복구가 그 자산만 조용히 죽는다.
*
* @effects static_asset_tag_recovers_to_api_url
*/
function loadInlineStaticToLegacy(): (url: string) => string | null {
const bladePath = resolve(__dirname, '../../../../views/partials/asset-url-recovery.blade.php');
const source = readFileSync(bladePath, 'utf-8');
const start = source.indexOf('function staticToLegacy(url) {');
expect(start, 'blade 파샬에서 staticToLegacy 를 찾지 못했다').toBeGreaterThan(-1);
let depth = 0;
let end = start;
for (let i = source.indexOf('{', start); i < source.length; i += 1) {
if (source[i] === '{') depth += 1;
else if (source[i] === '}') {
depth -= 1;
if (depth === 0) {
end = i + 1;
break;
}
}
}
// eslint-disable-next-line no-new-func
return new Function(
`${source.slice(start, end)}\nreturn staticToLegacy;`,
)() as (url: string) => string | null;
}
const staticCases = [
'/build/ext/1234/templates/sirsoft-basic/assets/css/components.css',
'/build/ext/1234/templates/sirsoft-basic/assets/js/components.iife.js',
'/build/ext/1234/bundles/modules.js',
'/build/ext/1234/bundles/plugins.css',
'/build/ext/1234/templates/sirsoft-basic/routes.json',
'/build/ext/1234/templates/sirsoft-basic/components.json',
'/build/ext/1234/templates/sirsoft-basic/lang/ko.json',
];
it('모든 정적 게시 URL 에서 두 구현의 결과가 동일하다', () => {
const inline = loadInlineStaticToLegacy();
for (const url of staticCases) {
expect(inline(url), `blade 인라인 역변환기가 ${url} 을 변환하지 못했다`).not.toBeNull();
expect(inline(url), `역변환 규칙 드리프트: ${url}`).toBe(staticToLegacy(url));
}
});
it('정적 게시 경로가 아닌 URL 은 두 구현 모두 변환하지 않는다', () => {
const inline = loadInlineStaticToLegacy();
for (const url of ['/build/core/template-engine.min.js', '/api/templates/t/routes.json', '']) {
expect(inline(url)).toBeNull();
expect(staticToLegacy(url)).toBeNull();
}
});
});
describe('정적 게시 역변환 × 자산 URL 모드 (#122 F15 — 확장자 404 서버)', () => {
/**
* 확장자 형태를 404 로 돌려주는 서버(#486 의 대상)에서는 역변환 결과인
* `/api/…/style.css` 가 **다시** 404 다. `recoverStylesheet` 는 링크당 1회만
* 교체하므로, 그 한 번이 서빙 불가능한 형태로 끝나면 `toExtensionless` 가
* 실행될 기회 없이 CSS 가 영구히 붙지 않는다. `<script>` 는 재시도 예산이
* 남아 다음 시도에서 자연 복구되지만 `<link>` 는 예산이 1회다.
*
* @effects static_asset_tag_recovers_to_api_url
*/
function loadPartialApi(): any {
const bladePath = resolve(__dirname, '../../../../views/partials/asset-url-recovery.blade.php');
const source = readFileSync(bladePath, 'utf-8');
// 파샬 상단 blade 주석이 태그 리터럴을 본문에 담고 있어, 맨 앞부터 찾으면
// 주석 속 리터럴을 먼저 잡는다. 주석 종료(`--}}`) 뒤에서 실제 태그를 찾는다.
const afterComment = source.indexOf('--}}');
const open = source.indexOf('<script>', afterComment === -1 ? 0 : afterComment);
const close = source.indexOf('</script>', open);
expect(open, 'blade 파샬에서 스크립트 태그를 찾지 못했다').toBeGreaterThan(-1);
expect(close, 'blade 파샬에서 스크립트 종료 태그를 찾지 못했다').toBeGreaterThan(open);
// blade echo(`{{ ... }}`)는 JS 가 아니므로 리터럴로 치환한다
const js = source.slice(open + '<script>'.length, close).replace(/{{[^}]*}}/g, '1234');
// eslint-disable-next-line no-new-func
new Function(js)();
return (globalThis as any).window.__g7AssetUrl;
}
const staticHref = '/build/ext/1234/templates/sirsoft-basic/assets/css/components.css';
beforeEach(() => {
(globalThis as any).window.__g7AssetUrl = undefined;
(globalThis as any).window.__g7AssetUrlMode = undefined;
});
it('확장자 모드에서는 확장자 형태 API URL 로 교체한다', () => {
const api = loadPartialApi();
const link = document.createElement('link');
link.setAttribute('href', staticHref);
api.recoverStylesheet(link);
expect(link.getAttribute('href')).toBe(
'/api/templates/assets/sirsoft-basic/css/components.css?v=1234',
);
});
it('extensionless 모드에서는 확장자 없는 형태까지 도달한다', () => {
(globalThis as any).window.__g7AssetUrlMode = MODE_EXTENSIONLESS;
const api = loadPartialApi();
const link = document.createElement('link');
link.setAttribute('href', staticHref);
api.recoverStylesheet(link);
expect(
link.getAttribute('href'),
'확장자 형태로 끝나면 그 서버가 다시 404 를 돌려주고 재시도 예산은 이미 소진된다',
).toBe('/api/templates/assets/sirsoft-basic?file=css%2Fcomponents.css&v=1234');
});
it('교체는 링크당 1회로 유지된다 (L1)', () => {
const api = loadPartialApi();
const link = document.createElement('link');
link.setAttribute('href', staticHref);
api.recoverStylesheet(link);
const first = link.getAttribute('href');
api.recoverStylesheet(link);
expect(link.getAttribute('href')).toBe(first);
});
});
describe('L6 — 인스톨러 프로브도 본문 토큰 + Content-Type 으로 판정', () => { describe('L6 — 인스톨러 프로브도 본문 토큰 + Content-Type 으로 판정', () => {
/** /**
* 프로브 판정 로직은 두 곳에 존재한다 — 관리자 핸들러(`detectAssetUrlModeHandler`)와 * 프로브 판정 로직은 두 곳에 존재한다 — 관리자 핸들러(`detectAssetUrlModeHandler`)와
@@ -0,0 +1,152 @@
/**
* 정적 우선 fetch 폴백 테스트 (#122 S3).
*
* 정적 게시본 miss(404/네트워크 실패)가 legacy API 폴백으로 즉시 수렴하고,
* 폴백이 warn 로그로 관측 가능함을 잠근다 (조용한 폴백 금지 — 자가 치유
* 실패를 발견할 유일한 통로).
*
* @effects static_first_fetch_falls_back_to_api_on_miss, fallback_is_observable_via_console_warn
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { fetchStaticFirst } from '../fetchStaticFirst';
import { extStaticUrl, extStaticVersion, staticToLegacy } from '../assetUrl';
function ok(body: unknown = {}): Response {
return {
ok: true,
status: 200,
json: async () => body,
} as unknown as Response;
}
function notFound(): Response {
return { ok: false, status: 404 } as unknown as Response;
}
describe('fetchStaticFirst (#122)', () => {
let fetchMock: ReturnType<typeof vi.fn>;
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
delete (globalThis as any).G7Config;
});
it('정적 200 이면 legacy 를 호출하지 않는다', async () => {
fetchMock.mockResolvedValue(ok({ from: 'static' }));
const response = await fetchStaticFirst('/build/ext/7/templates/t/routes.json', '/api/templates/t/routes.json');
expect(response.ok).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(String(fetchMock.mock.calls[0][0])).toContain('/build/ext/7/');
});
it('정적 404 이면 legacy 로 1회 폴백하고 warn 을 남긴다', async () => {
fetchMock.mockImplementation((url: string) =>
Promise.resolve(String(url).includes('/build/ext/') ? notFound() : ok({ from: 'legacy' }))
);
const response = await fetchStaticFirst('/build/ext/7/templates/t/routes.json', '/api/templates/t/routes.json');
expect(response.ok).toBe(true);
const urls = fetchMock.mock.calls.map((c: any[]) => String(c[0]));
expect(urls).toHaveLength(2);
expect(urls[1]).toContain('/api/templates/t/routes.json');
expect(warnSpy).toHaveBeenCalled();
});
it('정적 네트워크 실패면 legacy 로 폴백한다', async () => {
fetchMock.mockImplementation((url: string) =>
String(url).includes('/build/ext/')
? Promise.reject(new TypeError('Failed to fetch'))
: Promise.resolve(ok({ from: 'legacy' }))
);
const response = await fetchStaticFirst('/build/ext/7/templates/t/routes.json', '/api/templates/t/routes.json');
expect(response.ok).toBe(true);
expect(warnSpy).toHaveBeenCalled();
});
it('staticUrl 이 null(staticBase 미주입)이면 legacy 로 직행한다', async () => {
fetchMock.mockResolvedValue(ok());
await fetchStaticFirst(null, '/api/templates/t/routes.json');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(String(fetchMock.mock.calls[0][0])).toContain('/api/');
});
it('legacy 응답은 4xx/5xx 라도 그대로 반환한다 (호출부 분기 보존)', async () => {
fetchMock.mockImplementation((url: string) =>
Promise.resolve(String(url).includes('/build/ext/') ? notFound() : ({ ok: false, status: 500 } as unknown as Response))
);
const response = await fetchStaticFirst('/build/ext/7/x.json', '/api/x.json');
expect(response.status).toBe(500);
});
});
describe('extStaticUrl / extStaticVersion (#122)', () => {
afterEach(() => {
delete (globalThis as any).G7Config;
});
it('staticBase 미주입 시 null', () => {
delete (globalThis as any).G7Config;
expect(extStaticUrl('templates/t/routes.json')).toBeNull();
expect(extStaticVersion()).toBeNull();
});
it('staticBase 기반 URL 조합 + 버전 추출', () => {
(globalThis as any).G7Config = { staticBase: '/build/ext/1234' };
expect(extStaticUrl('templates/t/routes.json')).toBe('/build/ext/1234/templates/t/routes.json');
expect(extStaticVersion()).toBe(1234);
});
it('forVersion 이 페이지 버전과 다르면 그 버전 디렉토리를 조합한다 (핸드셰이크 재로드)', () => {
(globalThis as any).G7Config = { staticBase: '/build/ext/1234' };
expect(extStaticUrl('templates/t/routes.json', 5678)).toBe('/build/ext/5678/templates/t/routes.json');
expect(extStaticUrl('templates/t/routes.json', 1234)).toBe('/build/ext/1234/templates/t/routes.json');
});
});
describe('staticToLegacy (#122 F15 — 역변환 규칙)', () => {
it('템플릿 dist 에셋 → 종전 자산 API', () => {
expect(staticToLegacy('/build/ext/1234/templates/sirsoft-basic/assets/css/components.css')).toBe(
'/api/templates/assets/sirsoft-basic/css/components.css?v=1234'
);
expect(staticToLegacy('/build/ext/1234/templates/sirsoft-basic/assets/js/components.iife.js')).toBe(
'/api/templates/assets/sirsoft-basic/js/components.iife.js?v=1234'
);
});
it('병합 번들 → 종전 번들 API', () => {
expect(staticToLegacy('/build/ext/1234/bundles/modules.js')).toBe('/api/modules/bundle.js?v=1234');
expect(staticToLegacy('/build/ext/1234/bundles/plugins.css')).toBe('/api/plugins/bundle.css?v=1234');
});
it('fetch 계층 리소스(routes/lang/components)도 역변환된다', () => {
expect(staticToLegacy('/build/ext/1234/templates/t/routes.json')).toBe('/api/templates/t/routes.json?v=1234');
expect(staticToLegacy('/build/ext/1234/templates/t/components.json')).toBe('/api/templates/t/components.json?v=1234');
expect(staticToLegacy('/build/ext/1234/templates/t/lang/ko.json')).toBe('/api/templates/t/lang/ko.json?v=1234');
});
it('정적 게시 경로가 아니면 null (`/build/core/**` 제외 유지)', () => {
expect(staticToLegacy('/build/core/template-engine.min.js')).toBeNull();
expect(staticToLegacy('/api/templates/t/routes.json')).toBeNull();
expect(staticToLegacy('')).toBeNull();
});
});
+104
View File
@@ -321,6 +321,110 @@ export function layoutPreviewUrl(token: string): string {
return suffixed(`/api/layouts/preview/${encodeURIComponent(token)}`, 'json'); return suffixed(`/api/layouts/preview/${encodeURIComponent(token)}`, 'json');
} }
/**
* 정적 게시(bake) 베이스 경로를 반환합니다 (#122).
*
* blade 가 게이트(프로덕션 + kill-switch + 게시 완료) 통과 시에만
* `window.G7Config.staticBase` (`/build/ext/{v}`) 를 주입한다. 부재 시 null —
* 소비자는 종전 API URL 로 직행한다.
*
* @returns 정적 베이스 경로 또는 null
* @since engine-v1.61.0
*/
export function extStaticBase(): string | null {
const base = (globalThis as any)?.G7Config?.staticBase;
return typeof base === 'string' && base !== '' ? base.replace(/\/+$/, '') : null;
}
/**
* 정적 게시 베이스에 담긴 캐시 버전을 반환합니다.
*
* @returns 버전 숫자 또는 null (베이스 부재/형식 불일치)
* @since engine-v1.61.0
*/
export function extStaticVersion(): number | null {
const base = extStaticBase();
if (!base) return null;
const match = base.match(/\/(\d+)$/);
return match ? Number(match[1]) : null;
}
/**
* 정적 게시본 내 파일의 URL 을 생성합니다 (#122).
*
* `forVersion` 을 주면 그 버전 디렉토리를 조합한다 — 핸드셰이크 재로드처럼
* 페이지 렌더 시점과 다른 버전을 요구하는 경우다. 그 버전이 아직 미게시면
* 404 가 나고, 호출부의 `fetchStaticFirst` 가 legacy API 로 폴백한다.
*
* 서버측 `App\Support\AssetUrl`(정적 게시 트리 규약)과 경로 규칙 1:1 —
* 실파일 확장자를 그대로 쓰므로 dualSuffix 접미사 규칙은 적용하지 않는다.
*
* @param path 게시 트리 상대 경로 (예: `templates/{id}/routes.json`)
* @param forVersion 명시 버전 (생략 시 페이지 렌더 버전)
* @returns 정적 URL 또는 null (staticBase 미주입)
* @since engine-v1.61.0
*/
export function extStaticUrl(path: string, forVersion?: number): string | null {
const base = extStaticBase();
if (!base) return null;
const normalizedPath = path.replace(/^\/+/, '');
if (forVersion !== undefined && forVersion > 0 && forVersion !== extStaticVersion()) {
return `${base.replace(/\/\d+$/, '')}/${forVersion}/${normalizedPath}`;
}
return `${base}/${normalizedPath}`;
}
/**
* 실패한 정적 게시 URL 을 종전 API URL 로 역변환합니다 (#122 F15).
*
* blade 인라인 복구기(`partials/asset-url-recovery.blade.php` 의 `staticToLegacy`)와
* **동일 규칙**이어야 한다 — 드리프트는 `assetUrlRecovery.test.ts` 의 대조 케이스가 잡는다.
* 변환 대상이 아니면(정적 게시 경로가 아니면) null. `/build/core/**` 등 다른 정적
* 파일은 대상이 아니다.
*
* @param url 실패한 URL
* @returns 종전 API URL 또는 null
* @since engine-v1.61.0
*/
export function staticToLegacy(url: string): string | null {
if (!url) return null;
const origin = (globalThis as any)?.location?.origin;
const relative = origin && url.startsWith(origin) ? url.slice(origin.length) : url;
const match = relative.match(/^\/build\/ext\/(\d+)\/(.+)$/);
if (!match) return null;
const version = match[1];
const rest = match[2].split('?')[0];
const templateAssetMatch = rest.match(/^templates\/([^/]+)\/assets\/(.+)$/);
if (templateAssetMatch) {
return `/api/templates/assets/${templateAssetMatch[1]}/${templateAssetMatch[2]}?v=${version}`;
}
const bundleMatch = rest.match(/^bundles\/(modules|plugins)\.(js|css)$/);
if (bundleMatch) {
return `/api/${bundleMatch[1]}/bundle.${bundleMatch[2]}?v=${version}`;
}
const templateFetchMatch = rest.match(/^templates\/([^/]+)\/(routes\.json|components\.json|lang\/([a-zA-Z-]+)\.json)$/);
if (templateFetchMatch) {
const [, identifier, kind] = templateFetchMatch;
const suffixless = kind.replace(/\.json$/, '');
return `/api/templates/${identifier}/${suffixless}.json?v=${version}`;
}
return null;
}
/** /**
* 서버가 확장자 형태로 만들어 내려준 URL 을 **현재 모드**에 맞게 변환합니다. * 서버가 확장자 형태로 만들어 내려준 URL 을 **현재 모드**에 맞게 변환합니다.
* *
@@ -0,0 +1,53 @@
/**
* 정적 게시(bake) 우선 fetch 헬퍼 (#122).
*
* 부트스트랩 리소스(routes/lang/components)는 정적 게시본(`/build/ext/{v}/…`)을
* 먼저 시도하고, 응답이 `!ok`(부분 게시·GC 직후 404 포함)이거나 네트워크 실패면
* **즉시** 종전 API URL 로 폴백한다. 폴백은 관측 가능해야 자가 치유 실패를
* 발견할 수 있으므로 warn 1줄을 남긴다 (조용한 폴백 금지).
*
* legacy 측은 `fetchWithRetry` 를 재사용해 종전의 네트워크 복원력(#463)을 유지한다.
*
* @since engine-v1.61.0
*/
import { fetchWithRetry, type RetryOptions } from '../template-engine/networkResilience';
/**
* 정적 URL 우선 + legacy API 폴백 fetch.
*
* @param staticUrl 정적 게시 URL (null 이면 legacy 직행 — staticBase 미주입)
* @param legacyUrl 종전 API URL
* @param options legacy 측 재시도 옵션 + fetch init (정적 측은 init 만 사용)
* @returns HTTP 응답 (legacy 측은 4xx/5xx 포함 — 호출부의 기존 분기 보존)
*/
export async function fetchStaticFirst(
staticUrl: string | null,
legacyUrl: string,
options: RetryOptions & { init?: RequestInit } = {}
): Promise<Response> {
if (!staticUrl) {
return fetchWithRetry(legacyUrl, options);
}
try {
const response = await fetch(staticUrl, options.init);
if (response.ok) {
return response;
}
// 디버그 게이트 없는 console.warn — 폴백은 자가 치유 실패를 발견할 유일한
// 신호라 프로덕션 콘솔에서도 보여야 한다 (bootstrap 인라인 재시도와 동일 사상)
console.warn(
`[fetchStaticFirst] Static fast path miss (${response.status}) — falling back to API: ${staticUrl}`
);
} catch (error) {
console.warn(
`[fetchStaticFirst] Static fast path fetch failed — falling back to API: ${staticUrl}`,
error
);
}
return fetchWithRetry(legacyUrl, options);
}
@@ -5,6 +5,26 @@
> >
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/) > 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
## [engine-v1.61.0] - 2026-08-25
### Added
#### 부트스트랩 리소스 정적 게시(bake) 우선 로더 + API 폴백 (#122)
- 서버가 병합 결과물(routes/lang/components)을 캐시 버전 디렉토리(`/build/ext/{v}/…`)에 실파일로 게시하면, blade 가 `window.G7Config.staticBase` 를 주입하고 프론트 로더가 그 정적 경로를 **우선** 시도한다. 정적 응답이 `!ok`(부분 게시·GC 직후 404 포함)이거나 네트워크 실패면 **즉시** 종전 API URL 로 폴백한다 (`fetchStaticFirst` — legacy 측은 기존 `fetchWithRetry` 네트워크 복원력 유지, 폴백 발생은 console.warn 1줄로 관측 가능).
- `assetUrl.ts` 에 `extStaticBase()`/`extStaticVersion()`/`extStaticUrl()`/`staticToLegacy()` 추가 — 서버측 `AssetUrl` 의 게시 트리 규약과 경로 규칙 1:1. `staticBase` 미주입(비프로덕션/kill-switch/미게시)이면 전 리소스가 종전 API 직행으로, 기존 동작과 바이트 동일하다.
- 소비자 전환: TemplateApp routes(초기 burst + 핸드셰이크 재로드 — 재로드는 새 버전 정적 경로 조합, 미게시 시 legacy 폴백) · TranslationEngine lang(`bustCache` 재로드는 목적상 legacy 직행) · ComponentRegistry components(편집기 v0 경로는 legacy 유지).
- 태그 계층 자가 복구: `asset-url-recovery` 파샬에 `staticToLegacy` 역변환 추가 — 실패한 `/build/ext/{v}/…` 태그 자산(`<link>`/`<script>`)을 종전 `/api/…` URL 로 1회 전환한다 (GC 된 구버전 자산을 참조하는 캐시된 HTML 방어, `/build/core/**` 는 계속 변환 제외, 단방향 1회·자동 reload 금지 불변식 유지).
- `ModuleAssetLoader` 확장 병합 번들(JS/CSS)도 동일 폴백 — 정적 번들 URL 은 1회만 시도하고 미스 시 `staticToLegacy` + `convertToCurrentMode` 로 종전 API URL 에 합류한다 (JS 는 기존 재시도 예산을 레거시에서 이어가고, CSS 는 같은 `<link>` 의 href 를 1회 교체). 종전에는 같은 정적 URL 만 재시도해 게시본 소실 시 확장 핸들러가 조용히 전부 미등록되었다.
- 그 역변환 결과는 언제나 확장자 형태이므로, 확장자 없는 형태로 이미 확정된 모드에서는 결과를 다시 `toExtensionless` 로 넘긴다. 확장자 주소를 가로채는 서버(자산 URL 이중 모드의 대상)에서 CSS `<link>` 는 교체 예산이 1회뿐이라, 그 한 번이 확장자 형태로 끝나면 스타일이 영구히 붙지 않았다 (`<script>` 는 재시도 예산이 남아 다음 시도에서 모드 전환 분기가 발동하므로 영향 없음).
### Fixed
#### stale 캐시버전 재방문의 부트 이중 로드 (#122)
- localStorage `g7_cache_version` 이 stale 이면 첫 burst 가 구버전 `?v` 로 나가고, config 핸드셰이크가 routes 재로드 + lang(ko·en) `_=` 버스터 재다운로드를 유발했다 (~500KB 중복, 부트 ~1.3s 연장). blade 는 이미 현재 버전을 `G7Config.cache_version` 으로 주입하고 있었으므로, TemplateApp 의 캐시 버전 시드를 **blade 주입값 우선**(부재 시 localStorage 폴백)으로 교체하고, 핸드셰이크 재로드 판정 기준을 "이번 burst 가 실제 사용한 버전" 으로 바꿨다. 렌더~부트 사이 bump 는 종전대로 핸드셰이크가 복구한다.
- `ComponentRegistry.loadComponents()` 에 옵셔널 `cacheVersion` 파라미터를 추가해 components.json 요청에 `?v` 를 부착하고, 정적 manifestCache 키에 버전을 포함해 확장 라이프사이클 전후의 stale 매니페스트 교차 오염을 막았다 (편집기 경로는 버전 미전달 — v0 캐시 키 + 무버전 URL 하위 호환).
## [engine-v1.60.6] - 2026-08-22 ## [engine-v1.60.6] - 2026-08-22
### Fixed ### Fixed
@@ -12,8 +12,8 @@
import React, { type ComponentType } from 'react'; import React, { type ComponentType } from 'react';
import { createLogger } from '../utils/Logger'; import { createLogger } from '../utils/Logger';
import { fetchWithRetry } from './networkResilience'; import { suffixed, extStaticUrl } from '../support/assetUrl';
import { suffixed } from '../support/assetUrl'; import { fetchStaticFirst } from '../support/fetchStaticFirst';
const logger = createLogger('ComponentRegistry'); const logger = createLogger('ComponentRegistry');
@@ -126,6 +126,9 @@ export class ComponentRegistry {
/** 컴포넌트 매니페스트 */ /** 컴포넌트 매니페스트 */
private manifest: ComponentManifest | null = null; private manifest: ComponentManifest | null = null;
/** 확장 캐시 버전 (0 = 무버전 URL — 편집기 경로) */
private cacheVersion = 0;
/** 로딩 상태 */ /** 로딩 상태 */
private loadingState: LoadingState = 'idle'; private loadingState: LoadingState = 'idle';
@@ -182,8 +185,11 @@ export class ComponentRegistry {
* *
* @param templateId 템플릿 식별자 * @param templateId 템플릿 식별자
* @param templateType 템플릿 타입 (admin 또는 user) * @param templateType 템플릿 타입 (admin 또는 user)
* @param cacheVersion 확장 캐시 버전 — 0(기본)이면 무버전 URL(편집기 경로,
* 서버는 `?v` 생략 시 현재 버전 폴백 #588). 매니페스트 캐시 키에도 포함되어
* 버전 간 교차 오염을 막는다 (#122, @since engine-v1.61.0)
*/ */
public async loadComponents(templateId: string, templateType: string): Promise<void> { public async loadComponents(templateId: string, templateType: string, cacheVersion: number = 0): Promise<void> {
if (this.loadingState === 'loading') { if (this.loadingState === 'loading') {
throw new ComponentRegistryError( throw new ComponentRegistryError(
'Components are already being loaded', 'Components are already being loaded',
@@ -199,6 +205,7 @@ export class ComponentRegistry {
this.loadingState = 'loading'; this.loadingState = 'loading';
this.templateId = templateId; this.templateId = templateId;
this.templateType = templateType; this.templateType = templateType;
this.cacheVersion = cacheVersion;
this.error = null; this.error = null;
try { try {
@@ -235,8 +242,8 @@ export class ComponentRegistry {
); );
} }
// 캐시 키 생성 // 캐시 키 생성 (버전 포함 — 확장 라이프사이클 후 stale 매니페스트 교차 오염 방지)
const cacheKey = `${this.templateId}:${this.templateType}`; const cacheKey = `${this.templateId}:${this.templateType}:v${this.cacheVersion || 0}`;
// 캐시 확인 // 캐시 확인
if (ComponentRegistry.manifestCache.has(cacheKey)) { if (ComponentRegistry.manifestCache.has(cacheKey)) {
@@ -248,8 +255,20 @@ export class ComponentRegistry {
// 캐시 미스 - API에서 로드 // 캐시 미스 - API에서 로드
// 네트워크 일시 실패(응답 없음)에만 재시도. HTTP 에러는 아래 !ok 분기가 종전대로 처리. // 네트워크 일시 실패(응답 없음)에만 재시도. HTTP 에러는 아래 !ok 분기가 종전대로 처리.
// @since engine-v1.53.0 // @since engine-v1.53.0
const manifestUrl = suffixed(`/api/templates/${this.templateId}/components`, 'json'); const manifestUrl = suffixed(
const response = await fetchWithRetry(manifestUrl, { label: 'components.json' }); `/api/templates/${this.templateId}/components`,
'json',
this.cacheVersion > 0 ? this.cacheVersion : null,
);
// 정적 게시본(bake) 우선 (#122) — 편집기 경로(v0)는 legacy 직행 유지.
// miss 는 fetchStaticFirst 가 legacy 로 폴백하고, legacy 측이 fetchWithRetry 를 재사용한다.
const response = await fetchStaticFirst(
this.cacheVersion > 0
? extStaticUrl(`templates/${this.templateId}/components.json`, this.cacheVersion)
: null,
manifestUrl,
{ label: 'components.json' }
);
if (!response.ok) { if (!response.ok) {
throw new ComponentRegistryError( throw new ComponentRegistryError(
@@ -13,7 +13,8 @@
*/ */
import { createLogger } from '../utils/Logger'; import { createLogger } from '../utils/Logger';
import { suffixed } from '../support/assetUrl'; import { suffixed, extStaticUrl } from '../support/assetUrl';
import { fetchStaticFirst } from '../support/fetchStaticFirst';
// 순환 import (DataBindingEngine → TranslationEngine → DataBindingEngine) 이지만 // 순환 import (DataBindingEngine → TranslationEngine → DataBindingEngine) 이지만
// 양쪽 모두 모듈 평가 시점이 아니라 메서드 실행 시점에만 서로를 참조하므로 // 양쪽 모두 모듈 평가 시점이 아니라 메서드 실행 시점에만 서로를 참조하므로
// live binding 이 채워진 뒤에 사용된다. // live binding 이 채워진 뒤에 사용된다.
@@ -242,7 +243,15 @@ export class TranslationEngine {
extraParams.length > 0 ? extraParams.join('&') : undefined, extraParams.length > 0 ? extraParams.join('&') : undefined,
); );
const response = await fetch(url); // 정적 게시본(bake) 우선 (#122) — bustCache 재로드는 목적상 정적 캐시를
// 우회해야 하므로 legacy 직행. miss 는 fetchStaticFirst 가 legacy 로 폴백.
const staticUrl = ! bustCache && this.cacheVersion > 0
? extStaticUrl(`templates/${templateId}/lang/${locale}.json`, this.cacheVersion)
: null;
const response = staticUrl !== null
? await fetchStaticFirst(staticUrl, url, { label: `lang/${locale}.json` })
: await fetch(url);
if (!response.ok) { if (!response.ok) {
throw new TranslationError( throw new TranslationError(
@@ -0,0 +1,103 @@
/**
* ComponentRegistry 매니페스트 캐시 버전 테스트 (#122 작업 B)
*
* components.json 요청에 `?v`(확장 캐시 버전)가 부착되지 않아 확장
* 라이프사이클 직후 stale 매니페스트를 받을 수 있던 결함과, 정적
* manifestCache 가 버전 무시 키로 교차 오염되던 결함의 회귀를 막는다.
*
* 편집기 호출처(useEditorTemplateAssets)는 버전 미전달 — 옵셔널 파라미터로
* 하위 호환을 유지하고 그 경로는 v0 캐시 키 + 무버전 URL 을 사용한다
* (서버는 `?v` 생략 시 현재 버전 폴백 — #588).
*
* @effects versioned_boot_urls, manifest_cache_keys_are_version_scoped
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { ComponentRegistry } from '../ComponentRegistry';
/** components.json 정상 응답 (raw manifest) */
function manifestOk(): Response {
return {
ok: true,
status: 200,
json: async () => ({
version: '1.0.0',
templateId: 'sirsoft-basic',
components: { basic: [], composite: [], layout: [] },
}),
} as unknown as Response;
}
describe('ComponentRegistry 매니페스트 캐시 버전 (#122)', () => {
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
ComponentRegistry.resetInstance();
// 정적 manifestCache 초기화 (테스트 격리)
(ComponentRegistry as any).manifestCache = new Map();
fetchMock = vi.fn().mockResolvedValue(manifestOk());
vi.stubGlobal('fetch', fetchMock);
// IIFE 번들 전역 (sirsoft-basic → SirsoftBasic)
(window as any).SirsoftBasic = {};
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
delete (window as any).SirsoftBasic;
});
function manifestCalls(): string[] {
return fetchMock.mock.calls
.map((c: any[]) => String(c[0]))
.filter((u: string) => u.includes('/components'));
}
it('버전 전달 시 components.json 요청에 `?v` 가 부착된다', async () => {
const registry = ComponentRegistry.createIsolatedInstance();
await registry.loadComponents('sirsoft-basic', 'user', 7);
const calls = manifestCalls();
expect(calls).toHaveLength(1);
expect(calls[0]).toContain('v=7');
});
it('버전 미전달(편집기 경로) 시 무버전 URL 을 유지한다 (하위 호환)', async () => {
const registry = ComponentRegistry.createIsolatedInstance();
await registry.loadComponents('sirsoft-basic', 'user');
const calls = manifestCalls();
expect(calls).toHaveLength(1);
expect(calls[0]).not.toContain('v=');
});
it('정적 manifestCache 키가 버전을 포함해 교차 오염되지 않는다', async () => {
// v7 로드 → fetch 1회
const a = ComponentRegistry.createIsolatedInstance();
await a.loadComponents('sirsoft-basic', 'user', 7);
expect(manifestCalls()).toHaveLength(1);
// 같은 템플릿을 v9 로 로드 → v7 캐시를 재사용하면 안 된다 (fetch 2회째)
const b = ComponentRegistry.createIsolatedInstance();
await b.loadComponents('sirsoft-basic', 'user', 9);
expect(manifestCalls()).toHaveLength(2);
expect(manifestCalls()[1]).toContain('v=9');
// 같은 버전(v7) 재로드는 캐시 히트 (fetch 추가 없음)
const c = ComponentRegistry.createIsolatedInstance();
await c.loadComponents('sirsoft-basic', 'user', 7);
expect(manifestCalls()).toHaveLength(2);
});
it('버전 미전달 경로는 v0 캐시 키를 공유한다 (편집기 경로 캐시 유지)', async () => {
const a = ComponentRegistry.createIsolatedInstance();
await a.loadComponents('sirsoft-basic', 'user');
expect(manifestCalls()).toHaveLength(1);
const b = ComponentRegistry.createIsolatedInstance();
await b.loadComponents('sirsoft-basic', 'user');
expect(manifestCalls()).toHaveLength(1);
});
});
@@ -1,7 +1,3 @@
// audit:allow seo-renderer-parity-sync 이번 변경은 노드 키·컨텍스트 처리 추가가 아니라
// 리터럴 판정을 BindingShape 로 위임한 것이다. 봇(SEO) 측 ExpressionEvaluator 는
// 이미 'true'/'false'/'null' 을 리터럴로 해석하고 있었으므로(app/Seo/ExpressionEvaluator.php),
// 이 수정은 React 경로를 봇 경로에 맞춘 것이며 패리티를 좁힌다. 봇 측 대응 변경 없음.
/** /**
* 렌더링 관련 헬퍼 함수 모듈 * 렌더링 관련 헬퍼 함수 모듈
* *
+5
View File
@@ -63,6 +63,11 @@
// 미주입 시 클라이언트가 항상 `v0` 으로 호출 → `template:cache-clear` 가 v 와일드카드를 // 미주입 시 클라이언트가 항상 `v0` 으로 호출 → `template:cache-clear` 가 v 와일드카드를
// 처리하지 못해 캐시가 영구 stale 되는 결함이 발생. // 처리하지 못해 캐시가 영구 stale 되는 결함이 발생.
cache_version: {{ (int) ($extensionCacheVersion ?? 0) }}, cache_version: {{ (int) ($extensionCacheVersion ?? 0) }},
@if(($staticExtBase = \App\Support\AssetUrl::staticExtBase()) !== null)
// 정적 게시(bake) 베이스 — 게이트(프로덕션·kill-switch·게시 완료) 통과 시에만
// 주입된다. 프론트 로더가 routes/lang/components 를 이 경로에서 우선 수신 (#122).
staticBase: '{{ $staticExtBase }}',
@endif
// 자산 URL 모드 — 'extension'(기본) | 'extensionless'. // 자산 URL 모드 — 'extension'(기본) | 'extensionless'.
// 정적 최적화 블록이 동적 응답을 가로채는 서버에서 확장자 없는 형태로 전환. // 정적 최적화 블록이 동적 응답을 가로채는 서버에서 확장자 없는 형태로 전환.
// 부트스트랩 자가 복구가 런타임에 뒤집으므로 최상위 키로 노출한다. // 부트스트랩 자가 복구가 런타임에 뒤집으므로 최상위 키로 노출한다.
+5
View File
@@ -60,6 +60,11 @@
// 확장 캐시 버전 SSoT — 클라이언트 fetch (`?v=`) 동반 필수. // 확장 캐시 버전 SSoT — 클라이언트 fetch (`?v=`) 동반 필수.
// 자세한 설명은 admin.blade.php 참조. // 자세한 설명은 admin.blade.php 참조.
cache_version: {{ (int) ($extensionCacheVersion ?? 0) }}, cache_version: {{ (int) ($extensionCacheVersion ?? 0) }},
@if(($staticExtBase = \App\Support\AssetUrl::staticExtBase()) !== null)
// 정적 게시(bake) 베이스 — 게이트(프로덕션·kill-switch·게시 완료) 통과 시에만
// 주입된다. 프론트 로더가 routes/lang/components 를 이 경로에서 우선 수신 (#122).
staticBase: '{{ $staticExtBase }}',
@endif
// 자산 URL 모드 — 'extension'(기본) | 'extensionless'. // 자산 URL 모드 — 'extension'(기본) | 'extensionless'.
// 정적 최적화 블록(location ~* \.(js|css|json)$)이 동적 응답을 가로채는 // 정적 최적화 블록(location ~* \.(js|css|json)$)이 동적 응답을 가로채는
// 서버에서 확장자 없는 형태로 전환한다. 부트스트랩 자가 복구가 실패 시 // 서버에서 확장자 없는 형태로 전환한다. 부트스트랩 자가 복구가 실패 시
+8
View File
@@ -1070,6 +1070,14 @@ if (isset($_GET['ajax_action'])) {
<span>확장 번들 정리</span> <span>확장 번들 정리</span>
<span class="text-[10px] opacity-60">(ext-bundles:cleanup)</span> <span class="text-[10px] opacity-60">(ext-bundles:cleanup)</span>
</button> </button>
<button onclick="runCommand('ext-static:publish --force')" 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-static:publish)</span>
</button>
<button onclick="runCommand('ext-static: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-static:cleanup)</span>
</button>
<button onclick="runCommand('seo:prune-stats')" 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"> <button onclick="runCommand('seo:prune-stats')" 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>SEO 캐시 통계 정리</span> <span>SEO 캐시 통계 정리</span>
<span class="text-[10px] opacity-60">(seo:prune-stats)</span> <span class="text-[10px] opacity-60">(seo:prune-stats)</span>
@@ -20,6 +20,19 @@
L3 자동 location.reload() 금지. L3 자동 location.reload() 금지.
L5 서버 설정은 건드리지 않는다 (미인증 클라이언트의 전역 설정 변경 금지). L5 서버 설정은 건드리지 않는다 (미인증 클라이언트의 전역 설정 변경 금지).
L7 localStorage 캐시는 cache_version 을 키에 포함하고 TTL 을 둔다. L7 localStorage 캐시는 cache_version 을 키에 포함하고 TTL 을 둔다.
## 정적 게시(bake) 역변환 (#122 F15)
`/build/ext/{v}/…` 정적 게시본을 참조하는 태그 자산이 서빙 시점에 404 가 되면
(브라우저에 캐시된 구 HTML 이 GC 된 구버전 자산을 참조하는 등) 종전 `/api/…`
URL 로 1회 전환한다. `/build/core/**` 는 실물 정적 파일이라 계속 변환 제외.
역변환 규칙은 `resources/js/core/support/assetUrl.ts::staticToLegacy` 와 1:1 —
드리프트는 `assetUrlRecovery.test.ts` 의 대조 케이스가 잡는다.
역변환 결과는 언제나 확장자 형태이므로, 이미 확장자 없는 형태로 확정된 모드에서는
그 결과를 다시 `toExtensionless` 로 넘긴다. 그러지 않으면 위 배경의 서버에서
404 를 404 로 바꾸는 셈이고, CSS `<link>` 는 교체 예산이 1회라 두 번째 기회가 없다
(`<script>` 는 재시도 예산이 남아 다음 시도에서 모드 전환 분기가 발동한다).
--}} --}}
<script> <script>
(function () { (function () {
@@ -89,6 +102,52 @@
return null; return null;
} }
/**
* 실패한 정적 게시(bake) URL 을 종전 API URL 로 역변환한다 (#122 F15).
*
* `resources/js/core/support/assetUrl.ts::staticToLegacy` 와 **동일 규칙**이어야
* 한다 — 드리프트는 `assetUrlRecovery.test.ts` 의 대조 케이스가 잡는다.
* 변환 대상이 아니면(정적 게시 경로가 아니면) null. `/build/core/**` 등
* 다른 정적 파일은 대상이 아니다.
*
* @param url 실패한 URL
* @returns 종전 API URL 또는 null
*/
function staticToLegacy(url) {
if (!url) return null;
var origin = window.location && window.location.origin;
if (origin && url.indexOf(origin) === 0) {
url = url.slice(origin.length);
}
var match = url.match(/^\/build\/ext\/(\d+)\/(.+)$/);
if (!match) return null;
var version = match[1];
var rest = match[2].split('?')[0];
var templateAsset = rest.match(/^templates\/([^/]+)\/assets\/(.+)$/);
if (templateAsset) {
// audit:allow asset-url-builder-required reason: 코어 번들 로드 전 인라인 복구기 —
// import 불가라 규칙 사본을 직접 조립한다. 드리프트는 assetUrlRecovery.test.ts 대조가 잠근다.
return '/api/templates/assets/' + templateAsset[1] + '/' + templateAsset[2] + '?v=' + version;
}
var bundle = rest.match(/^bundles\/(modules|plugins)\.(js|css)$/);
if (bundle) {
return '/api/' + bundle[1] + '/bundle.' + bundle[2] + '?v=' + version;
}
var templateFetch = rest.match(/^templates\/([^/]+)\/(routes\.json|components\.json|lang\/([a-zA-Z-]+)\.json)$/);
if (templateFetch) {
return '/api/templates/' + templateFetch[1] + '/' +
templateFetch[2].replace(/\.json$/, '') + '.json?v=' + version;
}
return null;
}
/** /**
* 모드 전환을 1회 확정한다 (L1 — 단방향 1회). * 모드 전환을 1회 확정한다 (L1 — 단방향 1회).
* *
@@ -157,7 +216,26 @@
if (!link || link.getAttribute('data-g7-recovered') === '1') return; if (!link || link.getAttribute('data-g7-recovered') === '1') return;
link.setAttribute('data-g7-recovered', '1'); link.setAttribute('data-g7-recovered', '1');
var converted = toExtensionless(link.getAttribute('href') || ''); var href = link.getAttribute('href') || '';
// 정적 게시(bake) URL 실패 → 종전 API URL 로 1회 전환 (#122 F15).
// 모드 전환과 무관한 폴백이므로 switchToExtensionless 를 호출하지 않는다.
var legacy = staticToLegacy(href);
if (legacy) {
// 역변환 결과는 언제나 **확장자 형태**다. 그런데 이 파샬이 존재하는 이유가
// 바로 "확장자 형태를 404 로 돌려주는 서버"(#486)이므로, 그런 서버에서는
// 이 교체가 404 를 404 로 바꿀 뿐이다. 교체는 링크당 1회(L1)라 두 번째
// 기회가 없어 CSS 가 영구히 붙지 않는다 — 이미 확장자 없는 형태로
// 확정된 모드면 한 번의 교체로 서빙 가능한 형태까지 보낸다.
if (window.__g7AssetUrlMode === MODE_EXTENSIONLESS) {
legacy = toExtensionless(legacy) || legacy;
}
link.href = legacy;
return;
}
var converted = toExtensionless(href);
if (!converted) return; if (!converted) return;
switchToExtensionless(); switchToExtensionless();
@@ -169,6 +247,7 @@
window.__g7AssetUrl = { window.__g7AssetUrl = {
MODE_EXTENSIONLESS: MODE_EXTENSIONLESS, MODE_EXTENSIONLESS: MODE_EXTENSIONLESS,
toExtensionless: toExtensionless, toExtensionless: toExtensionless,
staticToLegacy: staticToLegacy,
switchToExtensionless: switchToExtensionless, switchToExtensionless: switchToExtensionless,
restoreCachedMode: restoreCachedMode, restoreCachedMode: restoreCachedMode,
recoverStylesheet: recoverStylesheet recoverStylesheet: recoverStylesheet
@@ -94,6 +94,9 @@
/** 모드 전환을 이미 시도했는지 (L1 — 페이지 수명당 1회) */ /** 모드 전환을 이미 시도했는지 (L1 — 페이지 수명당 1회) */
modeSwitched: false, modeSwitched: false,
/** 정적 게시 URL → API URL 전환을 이미 시도했는지 (#122 F15 — 페이지 수명당 1회) */
staticRecovered: false,
/** /**
* 정적 <script> 로드 실패 시 동적 재시도를 시작한다. * 정적 <script> 로드 실패 시 동적 재시도를 시작한다.
* *
@@ -115,6 +118,20 @@
return; return;
} }
// 정적 게시(bake) URL 실패 → 종전 API URL 로 즉시 1회 전환 (#122 F15).
// GC 된 구버전 자산을 참조하는 캐시된 HTML 등 서빙 시점 404 를 복구한다.
// 기존 예산(attempt)을 그대로 소비한다 (L2).
var legacy = (bootstrap.staticRecovered || !assetUrl || !assetUrl.staticToLegacy)
? null
: assetUrl.staticToLegacy(src);
if (legacy && legacy !== src) {
bootstrap.staticRecovered = true;
console.warn(LABEL + ' Static asset failed, retrying with API URL: ' + legacy);
bootstrap.replaceScript(legacy, attempt);
return;
}
// 자산 URL 모드 전환 (L1·L2) — 확장자 형태가 실패했고 아직 전환 전이라면, // 자산 URL 모드 전환 (L1·L2) — 확장자 형태가 실패했고 아직 전환 전이라면,
// 지연 재시도 대신 확장자 없는 형태로 **즉시 1회** 시도한다. // 지연 재시도 대신 확장자 없는 형태로 **즉시 1회** 시도한다.
// 이 시도는 기존 예산(attempt)을 그대로 소비하므로 총 시도 횟수는 불변이다. // 이 시도는 기존 예산(attempt)을 그대로 소비하므로 총 시도 횟수는 불변이다.
+2
View File
@@ -52,6 +52,8 @@ Schedule::command('queue:prune-failed')->dailyAt('04:10')->onOneServer();
Schedule::command('queue:prune-batches')->dailyAt('04:15')->onOneServer(); Schedule::command('queue:prune-batches')->dailyAt('04:15')->onOneServer();
Schedule::command('notification:cleanup')->dailyAt('04:20')->onOneServer(); Schedule::command('notification:cleanup')->dailyAt('04:20')->onOneServer();
Schedule::command('ext-bundles:cleanup')->dailyAt('04:25')->onOneServer(); Schedule::command('ext-bundles:cleanup')->dailyAt('04:25')->onOneServer();
// 오래된 부트스트랩 리소스 정적 게시 디렉토리 정리 (현재 + 직전 버전 보존)
Schedule::command('ext-static:cleanup')->dailyAt('04:28')->onOneServer();
Schedule::command('seo:prune-stats')->dailyAt('04:30')->onOneServer(); Schedule::command('seo:prune-stats')->dailyAt('04:30')->onOneServer();
Schedule::command('schedules:prune-history')->dailyAt('04:35')->onOneServer(); Schedule::command('schedules:prune-history')->dailyAt('04:35')->onOneServer();
Schedule::command('identity:prune-logs')->dailyAt('04:40')->onOneServer(); Schedule::command('identity:prune-logs')->dailyAt('04:40')->onOneServer();
@@ -911,4 +911,71 @@ class LayoutServingTest extends TestCase
// 메시지도 raw UTF-8 (레이아웃 제공 성공 메시지) // 메시지도 raw UTF-8 (레이아웃 제공 성공 메시지)
$this->assertMatchesRegularExpression('/[가-힣]/u', $body); $this->assertMatchesRegularExpression('/[가-힣]/u', $body);
} }
/**
* 프로덕션에서 레이아웃 응답은 공개 캐시 헤더를 유지한다 (#122 작업 D — successWithCache 환경 분기 동반 효과)
*
* @effects fallback_api_serves_etag_304
*/
public function test_layout_has_public_cache_headers_in_production(): void
{
app()['env'] = 'production';
$template = Template::create([
'identifier' => 'sirsoft-admin_basic',
'vendor' => 'sirsoft',
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
]);
$layout = TemplateLayout::create([
'template_id' => $template->id,
'name' => 'dashboard',
'content' => ['meta' => [], 'data_sources' => [], 'components' => []],
]);
$response = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json");
$response->assertStatus(200);
$this->assertNotNull($response->headers->get('ETag'));
$cacheControl = (string) $response->headers->get('Cache-Control');
$this->assertStringContainsString('public', $cacheControl);
$this->assertStringContainsString('max-age=3600', $cacheControl);
}
/**
* 개발 환경에서 레이아웃 응답은 no-cache (dev 레이아웃 반영성 — #122 작업 D 환경 분기)
*
* @effects fallback_api_no_cache_in_dev
*/
public function test_layout_no_cache_in_development(): void
{
app()['env'] = 'local';
$template = Template::create([
'identifier' => 'sirsoft-admin_basic',
'vendor' => 'sirsoft',
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
]);
$layout = TemplateLayout::create([
'template_id' => $template->id,
'name' => 'dashboard',
'content' => ['meta' => [], 'data_sources' => [], 'components' => []],
]);
$response = $this->getJson("/api/layouts/{$template->identifier}/{$layout->name}.json");
$response->assertStatus(200);
$cacheControl = (string) $response->headers->get('Cache-Control');
$this->assertStringContainsString('no-cache', $cacheControl);
$this->assertStringNotContainsString('max-age=3600', $cacheControl);
}
} }
@@ -0,0 +1,183 @@
<?php
namespace Tests\Feature\Api\Public;
use App\Enums\ExtensionStatus;
use App\Models\Module;
use App\Models\Plugin;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 모듈/플러그인 components.json 서빙의 폴백 API 캐시 계약 (#122 작업 C).
*
* `cachedJsonResponse` 가 ETag/304 조건부 캐시와 환경 분기(prod: public
* max-age / dev: no-cache)를 적용하는지 검증한다. 정적 게시본(fast path)
* 미스 시 이 API 가 폴백 경로이므로, 폴백 품질이 곧 미게시 상태의 부트 성능이다.
*/
class PublicComponentsCachingTest extends TestCase
{
use RefreshDatabase;
private string $moduleIdentifier;
private string $pluginIdentifier;
protected function setUp(): void
{
parent::setUp();
$this->moduleIdentifier = 'test-module'.uniqid();
$this->pluginIdentifier = 'test-plugin'.uniqid();
Module::factory()->create([
'identifier' => $this->moduleIdentifier,
'status' => ExtensionStatus::Active->value,
]);
Plugin::factory()->create([
'identifier' => $this->pluginIdentifier,
'status' => ExtensionStatus::Active->value,
]);
mkdir(base_path("modules/{$this->moduleIdentifier}"), 0755, true);
file_put_contents(
base_path("modules/{$this->moduleIdentifier}/components.json"),
json_encode(['composite' => ['TestWidget' => ['path' => 'components/TestWidget.tsx']]])
);
mkdir(base_path("plugins/{$this->pluginIdentifier}"), 0755, true);
file_put_contents(
base_path("plugins/{$this->pluginIdentifier}/components.json"),
json_encode(['composite' => ['TestPanel' => ['path' => 'components/TestPanel.tsx']]])
);
}
protected function tearDown(): void
{
foreach ([
base_path("modules/{$this->moduleIdentifier}"),
base_path("plugins/{$this->pluginIdentifier}"),
] as $dir) {
if (file_exists($dir)) {
array_map('unlink', glob($dir.'/*') ?: []);
rmdir($dir);
}
}
parent::tearDown();
}
/**
* 프로덕션: 200 응답에 ETag + public max-age + Vary 부착
*/
public function test_module_components_has_etag_and_cache_headers_in_production(): void
{
app()['env'] = 'production';
$response = $this->getJson("/api/modules/{$this->moduleIdentifier}/components.json");
$response->assertStatus(200);
$this->assertNotNull($response->headers->get('ETag'));
$cacheControl = $response->headers->get('Cache-Control');
$this->assertStringContainsString('public', $cacheControl);
$this->assertStringContainsString('max-age=3600', $cacheControl);
$this->assertStringContainsString('Accept-Encoding', (string) $response->headers->get('Vary'));
}
/**
* 프로덕션: If-None-Match 일치 시 304 (본문 미전송)
*
* @effects fallback_api_serves_etag_304
*/
public function test_module_components_returns_304_with_matching_etag(): void
{
app()['env'] = 'production';
$first = $this->getJson("/api/modules/{$this->moduleIdentifier}/components.json");
$etag = $first->headers->get('ETag');
$this->assertNotNull($etag);
$second = $this->getJson(
"/api/modules/{$this->moduleIdentifier}/components.json",
['If-None-Match' => $etag]
);
$second->assertStatus(304);
$this->assertSame('', $second->getContent());
$this->assertSame($etag, $second->headers->get('ETag'));
$this->assertStringContainsString('max-age=3600', (string) $second->headers->get('Cache-Control'));
}
/**
* 개발: no-cache (파일 수정 즉시 반영 — 브라우저 1h 캐시 비대칭 해소 F10)
*/
public function test_module_components_no_cache_in_development(): void
{
app()['env'] = 'local';
$response = $this->getJson("/api/modules/{$this->moduleIdentifier}/components.json");
$response->assertStatus(200);
$this->assertStringContainsString('no-cache', (string) $response->headers->get('Cache-Control'));
$this->assertStringNotContainsString('max-age=3600', (string) $response->headers->get('Cache-Control'));
}
/**
* 플러그인도 동일 계약 (200 헤더 + 304)
*/
public function test_plugin_components_caching_contract(): void
{
app()['env'] = 'production';
$first = $this->getJson("/api/plugins/{$this->pluginIdentifier}/components.json");
$first->assertStatus(200);
$etag = $first->headers->get('ETag');
$this->assertNotNull($etag);
$this->assertStringContainsString('max-age=3600', (string) $first->headers->get('Cache-Control'));
$second = $this->getJson(
"/api/plugins/{$this->pluginIdentifier}/components.json",
['If-None-Match' => $etag]
);
$second->assertStatus(304);
}
/**
* 플러그인도 개발 환경에서는 no-cache (모듈과 동일 축 — 한쪽만 잠그면 비대칭이 남는다)
*/
public function test_plugin_components_no_cache_in_development(): void
{
app()['env'] = 'local';
$response = $this->getJson("/api/plugins/{$this->pluginIdentifier}/components.json");
$response->assertStatus(200);
$this->assertStringContainsString('no-cache', (string) $response->headers->get('Cache-Control'));
$this->assertStringNotContainsString('max-age=3600', (string) $response->headers->get('Cache-Control'));
}
/**
* 내용 변경 시 ETag 불일치 → 200 재전송 (ETag 는 내용 해시)
*/
public function test_etag_changes_when_content_changes(): void
{
app()['env'] = 'production';
$first = $this->getJson("/api/modules/{$this->moduleIdentifier}/components.json");
$etag = $first->headers->get('ETag');
file_put_contents(
base_path("modules/{$this->moduleIdentifier}/components.json"),
json_encode(['composite' => ['Changed' => ['path' => 'components/Changed.tsx']]])
);
$second = $this->getJson(
"/api/modules/{$this->moduleIdentifier}/components.json",
['If-None-Match' => (string) $etag]
);
$second->assertStatus(200);
$this->assertNotSame($etag, $second->headers->get('ETag'));
}
}
@@ -49,6 +49,7 @@ class CoreScheduleRegistrationTest extends TestCase
'queue:prune-batches' => '15 4 * * *', 'queue:prune-batches' => '15 4 * * *',
'notification:cleanup' => '20 4 * * *', 'notification:cleanup' => '20 4 * * *',
'ext-bundles:cleanup' => '25 4 * * *', 'ext-bundles:cleanup' => '25 4 * * *',
'ext-static:cleanup' => '28 4 * * *',
'seo:prune-stats' => '30 4 * * *', 'seo:prune-stats' => '30 4 * * *',
'schedules:prune-history' => '35 4 * * *', 'schedules:prune-history' => '35 4 * * *',
'identity:prune-logs' => '40 4 * * *', 'identity:prune-logs' => '40 4 * * *',
@@ -0,0 +1,45 @@
<?php
namespace Tests\Feature\Http;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Tests\TestCase;
/**
* 정적 게시본 미스(`.json`)는 SPA catch-all 에 매칭되지 않는다 (#122 / 공개 #47 상호작용).
*
* catch-all 의 확장자 제외 lookahead(`(?!.*\.(js|css|…))`)는 끝 앵커가 없어 부분일치로
* 동작한다 — `.json` 은 `.js` 를 부분 문자열로 포함하므로 함께 제외된다. 덕분에 정적
* 게시본(`/build/ext/{v}/**.json`) 미스는 `template.dependencies` + `seo` 미들웨어와
* blade 풀 렌더를 거치지 않고 즉시 404 가 된다 (미스 비용 < API 폴백).
*
* 이 테스트는 그 성질을 계약으로 잠근다 — 훗날 lookahead 에 끝 앵커(`$`)를 붙이는
* "정리" 가 들어오면 정적 미스가 SPA 풀 렌더 경유로 조용히 비싸진다.
*/
class BuildPathCatchAllExclusionTest extends TestCase
{
/**
* 정적 게시 트리 미스는 유저 catch-all 에 매칭되지 않는다.
*
* @effects static_miss_skips_spa_catch_all
*/
public function test_build_경로는_유저_catch_all_에_매칭되지_않는다(): void
{
$this->expectException(NotFoundHttpException::class);
app('router')->getRoutes()->match(
Request::create('/build/ext/1787637589/templates/sirsoft-basic/lang/ko.json', 'GET')
);
}
/**
* 일반 SPA 경로는 여전히 catch-all 에 매칭된다 (과잉 제외 방지 가드).
*/
public function test_일반_경로는_여전히_유저_catch_all_에_매칭된다(): void
{
$route = app('router')->getRoutes()->match(Request::create('/some-page', 'GET'));
$this->assertContains('GET', $route->methods());
}
}
@@ -0,0 +1,455 @@
<?php
namespace Tests\Feature\Services;
use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Enums\ExtensionStatus;
use App\Models\Template;
use App\Services\ExtensionBundleService;
use App\Services\ExtensionStaticCacheService;
use App\Services\LanguagePackService;
use App\Services\TemplateService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
/**
* 부트스트랩 리소스 정적 게시(bake) 서비스 테스트 (#122 S1).
*
* 게시 트리 형상(병합 결과 = 폴백 API 페이로드), 원자성(tmp → rename → manifest
* 마지막), 멱등(skip), 비활성 산출물 부재, 경로 검증 거부, GC 보존 정책,
* kill-switch, terminating 트리거 환경 게이트를 검증한다.
*/
class ExtensionStaticCacheServiceTest extends TestCase
{
use RefreshDatabase;
private const VERSION = 987654321;
/** 테스트 전용 public 루트 (실 게시 트리 격리) */
private string $isolatedPublicPath;
protected function setUp(): void
{
parent::setUp();
// 실 게시 트리(public/build/ext) 격리.
//
// GC 케이스가 "삭제 3건 / VERSION+300 만 잔존" 을 단언하므로 이 테스트는
// base 디렉토리가 비어 있는 상태에서 출발해야 한다. 그런데 `baseDir()` 은
// `public_path()` 기준이라, base 를 비우는 것이 곧 **운영 중 사이트의 게시본을
// 지우는 것**이 된다 (테스트 1건 실행만으로 전량 소실 — 자가 치유가 복구하기
// 전까지 구 HTML 이 참조하는 자산이 404 → 폴백). 그래서 base 를 비우는 대신
// public 루트 자체를 테스트 전용 임시 경로로 돌린다.
$this->isolatedPublicPath = storage_path('framework/testing/public-'.getmypid());
File::ensureDirectoryExists($this->isolatedPublicPath);
$this->app->usePublicPath($this->isolatedPublicPath);
// 결정적 버전 시드 (getExtensionCacheVersion 폴백 재생성 방지)
Cache::put('g7:core:ext.cache_version', self::VERSION);
// 이전 실행이 비정상 종료로 남긴 게시 락 잔존물 정리 (결정적 실행 보장)
Cache::lock('ext-static.publish.'.self::VERSION, 300)->forceRelease();
Cache::lock('ext-static.publish.'.(self::VERSION + 1), 300)->forceRelease();
ExtensionStaticCacheService::resetPublishScheduleForTesting();
$this->cleanBaseDir();
}
protected function tearDown(): void
{
$this->cleanBaseDir();
File::deleteDirectory($this->isolatedPublicPath);
ExtensionStaticCacheService::resetPublishScheduleForTesting();
parent::tearDown();
}
private function cleanBaseDir(): void
{
File::deleteDirectory(public_path('build/ext'));
}
private function service(): ExtensionStaticCacheService
{
return app(ExtensionStaticCacheService::class);
}
private function createActiveTemplate(string $identifier = 'sirsoft-admin_basic', string $type = 'admin'): Template
{
return Template::create([
'identifier' => $identifier,
'vendor' => explode('-', $identifier)[0],
'name' => ['ko' => '테스트 템플릿', 'en' => 'Test Template'],
'version' => '1.0.0',
'type' => $type,
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '테스트', 'en' => 'Test'],
]);
}
/**
* 활성 템플릿 0개(설치 직전)여도 예외 없이 빈 게시가 성립한다.
*
* @scenario publish_state=unpublished, environment=production, trigger=manual_command, process_user=web
*/
public function test_publishes_empty_tree_when_no_active_templates(): void
{
$service = $this->service();
$this->assertTrue($service->publishCurrent());
$this->assertTrue($service->isPublished(self::VERSION));
$this->assertFileExists($service->versionDir(self::VERSION).'/manifest.json');
$this->assertFileExists($service->versionDir(self::VERSION).'/.htaccess');
}
/**
* lang 게시물은 폴백 API(serveLanguage)와 동일 페이로드다 (canonical 비교).
*
* @effects published_lang_matches_api_payload
*/
public function test_published_lang_matches_api_payload(): void
{
$this->createActiveTemplate();
$service = $this->service();
$this->assertTrue($service->publishCurrent());
$publishedPath = $service->versionDir(self::VERSION).'/templates/sirsoft-admin_basic/lang/ko.json';
$this->assertFileExists($publishedPath);
$apiResponse = $this->getJson('/api/templates/sirsoft-admin_basic/lang/ko.json');
$apiResponse->assertStatus(200);
$this->assertSame(
json_decode((string) $apiResponse->getContent(), true),
json_decode((string) file_get_contents($publishedPath), true),
'정적 게시본과 폴백 API 의 lang 페이로드가 canonical 동일해야 한다'
);
}
/**
* routes 게시물은 성공 봉투({"success":true,...,"data":...})를 포함한다 —
* 프론트 소비 코드(result.success / result.data) 무변경 보장.
*
* @effects published_routes_has_success_envelope
*/
public function test_published_routes_has_success_envelope(): void
{
$this->createActiveTemplate();
$service = $this->service();
$this->assertTrue($service->publishCurrent());
$publishedPath = $service->versionDir(self::VERSION).'/templates/sirsoft-admin_basic/routes.json';
$this->assertFileExists($publishedPath);
$published = json_decode((string) file_get_contents($publishedPath), true);
$this->assertTrue($published['success']);
$this->assertArrayHasKey('data', $published);
// 폴백 API 의 data 와 동일해야 한다
$apiResponse = $this->getJson('/api/templates/sirsoft-admin_basic/routes.json');
$apiResponse->assertStatus(200);
$this->assertSame($apiResponse->json('data'), $published['data']);
}
/**
* components.json 사본과 dist 에셋이 게시되고 `*.map` 은 제외된다.
*
* @effects published_tree_excludes_sourcemaps
*/
public function test_publishes_components_and_dist_assets_excluding_sourcemaps(): void
{
$this->createActiveTemplate();
$service = $this->service();
$this->assertTrue($service->publishCurrent());
$templateDir = $service->versionDir(self::VERSION).'/templates/sirsoft-admin_basic';
// components.json 사본 (실번들 파일이 존재)
$this->assertFileExists($templateDir.'/components.json');
$this->assertSame(
json_decode((string) file_get_contents(base_path('templates/sirsoft-admin_basic/components.json')), true),
json_decode((string) file_get_contents($templateDir.'/components.json'), true)
);
// dist 에셋 사본 존재 + 소스맵 부재
$this->assertDirectoryExists($templateDir.'/assets');
$maps = glob($templateDir.'/assets/*/*.map') ?: [];
$this->assertSame([], $maps, '소스맵은 게시 대상이 아니다');
}
/**
* 비활성 템플릿 산출물은 게시 트리에 부재한다 (활성 게이트의 정적 등가물).
*
* @effects published_tree_excludes_inactive_extensions
*/
public function test_inactive_template_is_absent_from_published_tree(): void
{
$this->createActiveTemplate();
Template::create([
'identifier' => 'sirsoft-basic',
'vendor' => 'sirsoft',
'name' => ['ko' => '비활성', 'en' => 'Inactive'],
'version' => '1.0.0',
'type' => 'user',
'status' => ExtensionStatus::Inactive->value,
'description' => ['ko' => '비활성', 'en' => 'Inactive'],
]);
$service = $this->service();
$this->assertTrue($service->publishCurrent());
$this->assertDirectoryExists($service->versionDir(self::VERSION).'/templates/sirsoft-admin_basic');
$this->assertDirectoryDoesNotExist($service->versionDir(self::VERSION).'/templates/sirsoft-basic');
}
/**
* 식별자 패턴 불일치(경로 세그먼트 위험) 템플릿은 게시에서 거부된다.
*
* @effects identifier_outside_pattern_rejected
*/
public function test_rejects_identifier_outside_whitelist_pattern(): void
{
$this->createActiveTemplate();
// vendor-name 패턴을 벗어나는 식별자 (DB 직접 생성으로 우회 가정)
Template::create([
'identifier' => 'UPPER..traversal',
'vendor' => 'upper',
'name' => ['ko' => '위험', 'en' => 'Danger'],
'version' => '1.0.0',
'type' => 'user',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '위험', 'en' => 'Danger'],
]);
$service = $this->service();
$this->assertTrue($service->publishCurrent());
$templatesDir = $service->versionDir(self::VERSION).'/templates';
$this->assertDirectoryExists($templatesDir.'/sirsoft-admin_basic');
$entries = array_map('basename', File::directories($templatesDir));
$this->assertSame(['sirsoft-admin_basic'], $entries, '패턴 불일치 식별자는 트리에 없어야 한다');
}
/**
* 멱등 — 게시 완료 상태에서 재호출은 skip (기존 게시물 유지), force 는 재게시.
*
* @scenario publish_state=published, environment=production, trigger=manual_command, process_user=web
*
* @effects publish_is_idempotent_until_forced
*/
public function test_publish_is_idempotent_and_force_republishes(): void
{
$service = $this->service();
$this->assertTrue($service->publishCurrent());
// 게시 후 심은 마커가 skip 재호출에서 살아남으면 재게시가 없었다는 증거
$marker = $service->versionDir(self::VERSION).'/idempotency-marker';
File::put($marker, 'x');
$this->assertTrue($service->publishCurrent());
$this->assertFileExists($marker);
// force 재게시는 디렉토리를 새로 만든다 → 마커 소실
$this->assertTrue($service->publishCurrent(force: true));
$this->assertFileDoesNotExist($marker);
$this->assertTrue($service->isPublished(self::VERSION));
}
/**
* 쓰기 단계 실패 시 예외를 삼키고 false + tmp 잔존물 정리 + manifest 부재.
*
* @scenario publish_state=partial, environment=production, trigger=lifecycle, process_user=web
*
* @effects write_failure_cleans_tmp_and_falls_back
*/
public function test_write_failure_cleans_tmp_and_returns_false(): void
{
$this->createActiveTemplate();
// 병합 SSoT 가 예외를 던지는 상황 시뮬레이션
$mock = $this->partialMock(TemplateService::class, function ($mock) {
$mock->shouldReceive('getLanguageDataWithModules')
->andThrow(new \RuntimeException('disk full'));
});
$service = new ExtensionStaticCacheService(
$mock,
app(TemplateRepositoryInterface::class),
app(ExtensionBundleService::class),
app(LanguagePackService::class),
);
$this->assertFalse($service->publishCurrent());
$this->assertFalse($service->isPublished(self::VERSION));
$base = $service->baseDir();
$tmpDirs = File::isDirectory($base)
? array_filter(File::directories($base), fn ($d) => str_ends_with($d, '.tmp'))
: [];
$this->assertSame([], array_values($tmpDirs), 'tmp 잔존물은 정리되어야 한다');
}
/**
* GC — 현재 버전 + 직전 1개 보존, 그 외 삭제 (tmp 잔존물 포함).
*
* @effects cleanup_keeps_current_and_previous_versions
*/
public function test_cleanup_keeps_current_and_previous_only(): void
{
$service = $this->service();
$this->assertTrue($service->publishCurrent());
// 과거 버전 3개 + 고아 tmp 시뮬레이션
foreach ([100, 200, 300] as $old) {
File::ensureDirectoryExists($service->versionDir($old));
File::put($service->versionDir($old).'/manifest.json', '{}');
}
File::ensureDirectoryExists($service->baseDir().'/400.tmp');
$deleted = $service->cleanup();
// 보존: 현재(VERSION) + 직전(300). 삭제: 100, 200, 400.tmp
$this->assertSame(3, $deleted);
$this->assertDirectoryExists($service->versionDir(self::VERSION));
$this->assertDirectoryExists($service->versionDir(300));
$this->assertDirectoryDoesNotExist($service->versionDir(200));
$this->assertDirectoryDoesNotExist($service->versionDir(100));
$this->assertDirectoryDoesNotExist($service->baseDir().'/400.tmp');
}
/**
* kill-switch(core.static_cache.enabled=false) — 게시 자체가 중단된다.
*
* @scenario publish_state=unpublished, environment=production, trigger=kill_switch, process_user=web
*
* @effects kill_switch_disables_publish_and_gate
*/
public function test_kill_switch_disables_publishing(): void
{
config(['core.static_cache.enabled' => false]);
$service = $this->service();
$this->assertFalse($service->publishCurrent());
$this->assertDirectoryDoesNotExist($service->baseDir());
}
/**
* terminating 트리거 — 비프로덕션(testing)에서는 예약되지 않는다.
*
* @scenario publish_state=unpublished, environment=dev, trigger=lifecycle, process_user=web
*
* @effects terminating_publish_gated_to_production
*/
public function test_terminating_publish_not_scheduled_outside_production(): void
{
ExtensionStaticCacheService::schedulePublishOnTerminate();
$this->app->terminate();
$this->assertDirectoryDoesNotExist($this->service()->baseDir());
}
/**
* terminating 트리거 — 프로덕션에서는 종료 시점의 현재 버전으로 게시된다.
*
* @scenario publish_state=unpublished, environment=production, trigger=lifecycle, process_user=web
*
* @effects terminating_publish_uses_final_version_after_burst
*/
public function test_terminating_publish_runs_in_production(): void
{
app()['env'] = 'production';
ExtensionStaticCacheService::schedulePublishOnTerminate();
// 예약 후 버전이 한 번 더 bump 되어도 실행 시점의 최종 버전으로 게시 (자연 병합)
Cache::put('g7:core:ext.cache_version', self::VERSION + 1);
$this->app->terminate();
$this->assertTrue($this->service()->isPublished(self::VERSION + 1));
$this->assertFalse($this->service()->isPublished(self::VERSION));
}
/**
* root 프로세스(sudo CLI)에서는 terminating 게시가 예약되지 않는다.
*
* root 로 게시하면 캐시 락 샤드 디렉토리(`storage/framework/cache/data/xx`)와
* 병합 번들(`storage/app/ext-bundles`)이 root 소유로 남고, 이후 웹 프로세스의
* 캐시 쓰기가 그 샤드에 해시되는 순간 Permission denied 로 죽는다
* (실사례: sudo 코어 업데이트 직후 전면 500). 게시는 다음 웹 렌더의
* 자가 치유(웹 계정)가 수행한다.
*
* @scenario publish_state=unpublished, environment=production, trigger=lifecycle, process_user=root_cli
*
* @effects root_cli_defers_publish_to_web_self_heal
*/
public function test_terminating_publish_not_scheduled_for_root_process(): void
{
app()['env'] = 'production';
ExtensionStaticCacheService::fakeRootProcessForTesting(true);
try {
ExtensionStaticCacheService::schedulePublishOnTerminate();
$this->app->terminate();
$this->assertDirectoryDoesNotExist($this->service()->baseDir());
} finally {
ExtensionStaticCacheService::fakeRootProcessForTesting(null);
}
}
/**
* terminating 게시 예약 플래그는 콜백 실행 시점에 리셋된다.
*
* 요청마다 앱 인스턴스를 새로 만드는 장수 프로세스(Octane 류)에서는 static 플래그만
* 프로세스에 살아남는다 — 리셋이 없으면 2번째 이후 bump 는 새 앱에 콜백이 등록되지
* 않은 채 플래그만 true 라 그 워커에서 영구 미게시가 되고, `AssetUrl` 자가 치유도
* 같은 플래그를 쓰므로 복구 경로가 없다. FPM(요청=프로세스)에서는 무영향.
*
* @scenario publish_state=published, environment=production, trigger=lifecycle, process_user=web
*
* @effects terminating_schedule_rearms_after_execution
*/
public function test_terminating_publish_rearms_after_callback_execution(): void
{
app()['env'] = 'production';
ExtensionStaticCacheService::schedulePublishOnTerminate();
$this->app->terminate();
$property = new \ReflectionProperty(ExtensionStaticCacheService::class, 'publishScheduled');
$this->assertFalse($property->getValue(), '콜백 실행 후 플래그가 리셋되어 재예약 가능해야 한다');
}
/**
* 게시 `.htaccess` 는 불변 캐시와 함께 mod_deflate 압축을 선언한다.
*
* 정적 서빙은 Laravel 압축 미들웨어(GzipEncodeResponse)를 우회하므로, Apache 에서는
* 게시 디렉토리가 스스로 압축을 선언해야 종전 API 대비 전송량 회귀가 없다
* (실측: lang/ko.json 524,915B 비압축 전송). nginx 는 규정 문서의 gzip 스니펫이 담당한다.
*
* @scenario publish_state=published, environment=production, trigger=manual_command, process_user=web
*
* @effects published_htaccess_declares_compression
*/
public function test_published_htaccess_declares_compression(): void
{
$service = $this->service();
$this->assertTrue($service->publishCurrent());
$htaccess = File::get($service->versionDir(self::VERSION).'/.htaccess');
$this->assertStringContainsString('mod_deflate.c', $htaccess);
$this->assertStringContainsString('application/json', $htaccess);
}
}
@@ -5,6 +5,7 @@ namespace Tests\Feature\Template;
use App\Contracts\Extension\CacheInterface; use App\Contracts\Extension\CacheInterface;
use App\Enums\ExtensionStatus; use App\Enums\ExtensionStatus;
use App\Models\Template; use App\Models\Template;
use App\Services\TemplateService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Tests\TestCase; use Tests\TestCase;
@@ -165,6 +166,151 @@ class PublicTemplateControllerTest extends TestCase
$this->assertEquals($response1->json('data'), $response2->json('data')); $this->assertEquals($response1->json('data'), $response2->json('data'));
} }
/**
* `?v` 명시 요청은 조건부 캐시 헤더를 받는다 (#122 작업 D)
*
* 버전 키드 URL 은 bump 시 URL 자체가 바뀌므로 1h 공개 캐시가 안전하다.
*/
public function test_versioned_routes_request_has_conditional_cache_headers(): void
{
app()['env'] = 'production';
Template::create([
'identifier' => 'sirsoft-admin_basic',
'vendor' => 'sirsoft',
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
]);
$response = $this->getJson('/api/templates/sirsoft-admin_basic/routes.json?v=1234');
$response->assertStatus(200)->assertJson(['success' => true]);
$this->assertNotNull($response->headers->get('ETag'));
$cacheControl = (string) $response->headers->get('Cache-Control');
$this->assertStringContainsString('public', $cacheControl);
$this->assertStringContainsString('max-age=3600', $cacheControl);
$this->assertStringContainsString('Accept-Encoding', (string) $response->headers->get('Vary'));
}
/**
* `?v` 명시 요청의 If-None-Match 일치 시 304 (#122 작업 D)
*/
public function test_versioned_routes_request_returns_304_with_matching_etag(): void
{
app()['env'] = 'production';
Template::create([
'identifier' => 'sirsoft-admin_basic',
'vendor' => 'sirsoft',
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
]);
$first = $this->getJson('/api/templates/sirsoft-admin_basic/routes.json?v=1234');
$etag = $first->headers->get('ETag');
$this->assertNotNull($etag);
$second = $this->getJson(
'/api/templates/sirsoft-admin_basic/routes.json?v=1234',
['If-None-Match' => $etag]
);
$second->assertStatus(304);
$this->assertSame('', $second->getContent());
}
/**
* 무버전 요청은 종전대로 공개 캐시 헤더가 없다 (#122 작업 D — 핸드셰이크 신선도 보존)
*/
public function test_unversioned_routes_request_has_no_public_cache_headers(): void
{
app()['env'] = 'production';
Template::create([
'identifier' => 'sirsoft-admin_basic',
'vendor' => 'sirsoft',
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
]);
$response = $this->getJson('/api/templates/sirsoft-admin_basic/routes.json');
$response->assertStatus(200);
$this->assertNull($response->headers->get('ETag'));
$this->assertStringNotContainsString('max-age=3600', (string) $response->headers->get('Cache-Control'));
}
/**
* 개발 환경에서 `?v` 요청도 no-cache (dev 파일 수정 즉시 반영 — #122 작업 D 환경 분기)
*/
public function test_versioned_routes_request_no_cache_in_development(): void
{
app()['env'] = 'local';
Template::create([
'identifier' => 'sirsoft-admin_basic',
'vendor' => 'sirsoft',
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
]);
$response = $this->getJson('/api/templates/sirsoft-admin_basic/routes.json?v=1234');
$response->assertStatus(200);
$cacheControl = (string) $response->headers->get('Cache-Control');
$this->assertStringContainsString('no-cache', $cacheControl);
$this->assertStringNotContainsString('max-age=3600', $cacheControl);
}
/**
* 열화 라우트 스냅샷은 `?v` 요청이어도 공개 캐시 헤더를 받지 않는다 (#493 대칭, #122 작업 D)
*
* 서버측 캐시 회피(#493)만으로는 부족하다 — 같은 `?v` URL 에 `public, max-age` 가
* 붙으면 브라우저/CDN 계층이 열화 응답을 1시간 박제하고, 버전은 이미 올라간 뒤라
* 스스로 회복되지 않는다. 정적 게시 경로(publishTemplate)의 열화 제외와 동일 규율.
*
* @effects degraded_snapshot_gets_no_public_cache
*/
public function test_degraded_routes_snapshot_has_no_public_cache_headers(): void
{
app()['env'] = 'production';
Template::create([
'identifier' => 'sirsoft-admin_basic',
'vendor' => 'sirsoft',
'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
'version' => '1.0.0',
'type' => 'admin',
'status' => ExtensionStatus::Active->value,
'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'],
]);
// proxy partial — 실제 인스턴스를 감싸 미지정 메서드는 실구현으로 위임
// (makePartial 은 생성자를 건너뛰어 typed property 미초기화 오류가 난다)
$service = \Mockery::mock(app(TemplateService::class));
$service->shouldReceive('lastRouteMergeWasDegraded')->andReturn(true);
$this->app->instance(TemplateService::class, $service);
$response = $this->getJson('/api/templates/sirsoft-admin_basic/routes.json?v=1234');
$response->assertStatus(200)->assertJson(['success' => true]);
$cacheControl = (string) $response->headers->get('Cache-Control');
$this->assertStringNotContainsString('public', $cacheControl);
$this->assertStringNotContainsString('max-age=3600', $cacheControl);
}
/** /**
* config.json 서빙이 캐시를 생성하고 installed manifest 버전을 반환하는지 테스트 (#588, 공개 #119) * config.json 서빙이 캐시를 생성하고 installed manifest 버전을 반환하는지 테스트 (#588, 공개 #119)
* *
@@ -5,6 +5,7 @@ namespace Tests\Feature\Template;
use App\Enums\ExtensionStatus; use App\Enums\ExtensionStatus;
use App\Models\Template; use App\Models\Template;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Tests\TestCase; use Tests\TestCase;
class TemplateAssetServingTest extends TestCase class TemplateAssetServingTest extends TestCase
@@ -12,7 +13,9 @@ class TemplateAssetServingTest extends TestCase
use RefreshDatabase; use RefreshDatabase;
private Template $activeTemplate; private Template $activeTemplate;
private string $testTemplatePath; private string $testTemplatePath;
private string $testIdentifier; private string $testIdentifier;
protected function setUp(): void protected function setUp(): void
@@ -30,7 +33,7 @@ class TemplateAssetServingTest extends TestCase
// 테스트용 템플릿 디렉토리 생성 // 테스트용 템플릿 디렉토리 생성
$this->testTemplatePath = base_path("templates/{$this->testIdentifier}/dist"); $this->testTemplatePath = base_path("templates/{$this->testIdentifier}/dist");
if (!file_exists($this->testTemplatePath)) { if (! file_exists($this->testTemplatePath)) {
mkdir($this->testTemplatePath, 0755, true); mkdir($this->testTemplatePath, 0755, true);
} }
} }
@@ -66,11 +69,11 @@ class TemplateAssetServingTest extends TestCase
*/ */
private function deleteDirectory(string $dir): bool private function deleteDirectory(string $dir): bool
{ {
if (!file_exists($dir)) { if (! file_exists($dir)) {
return true; return true;
} }
if (!is_dir($dir)) { if (! is_dir($dir)) {
return unlink($dir); return unlink($dir);
} }
@@ -79,7 +82,7 @@ class TemplateAssetServingTest extends TestCase
continue; continue;
} }
if (!$this->deleteDirectory($dir.DIRECTORY_SEPARATOR.$item)) { if (! $this->deleteDirectory($dir.DIRECTORY_SEPARATOR.$item)) {
return false; return false;
} }
} }
@@ -455,7 +458,7 @@ class TemplateAssetServingTest extends TestCase
// Assert // Assert
$response->assertStatus(200); $response->assertStatus(200);
// baseResponse를 통해 실제 응답 타입 확인 // baseResponse를 통해 실제 응답 타입 확인
$this->assertTrue($response->baseResponse instanceof \Symfony\Component\HttpFoundation\BinaryFileResponse, '200 response should be a file response'); $this->assertTrue($response->baseResponse instanceof BinaryFileResponse, '200 response should be a file response');
$this->assertNotNull($response->headers->get('ETag'), 'New ETag should be provided'); $this->assertNotNull($response->headers->get('ETag'), 'New ETag should be provided');
} }
@@ -534,7 +537,8 @@ class TemplateAssetServingTest extends TestCase
*/ */
public function test_serves_components_json(): void public function test_serves_components_json(): void
{ {
// Arrange // Arrange — public max-age 는 프로덕션 정책 (dev 는 no-cache 환경 분기, #122 작업 C)
app()['env'] = 'production';
$componentsPath = base_path("templates/{$this->testIdentifier}/components.json"); $componentsPath = base_path("templates/{$this->testIdentifier}/components.json");
file_put_contents($componentsPath, json_encode([ file_put_contents($componentsPath, json_encode([
'Button' => ['path' => 'components/Button.jsx'], 'Button' => ['path' => 'components/Button.jsx'],
@@ -555,6 +559,56 @@ class TemplateAssetServingTest extends TestCase
$this->assertStringContainsString('max-age=3600', $cacheControl); $this->assertStringContainsString('max-age=3600', $cacheControl);
} }
/**
* components.json If-None-Match 일치 시 304 (조건부 캐시 — #122 작업 C)
*/
public function test_components_json_returns_304_with_matching_etag(): void
{
// Arrange
app()['env'] = 'production';
$componentsPath = base_path("templates/{$this->testIdentifier}/components.json");
file_put_contents($componentsPath, json_encode([
'Button' => ['path' => 'components/Button.jsx'],
]));
// Act
$first = $this->get($this->templateUrl('components.json'));
$etag = $first->headers->get('ETag');
$second = $this->get($this->templateUrl('components.json'), ['If-None-Match' => (string) $etag]);
// Assert
$this->assertNotNull($etag);
$second->assertStatus(304);
$this->assertSame('', $second->getContent());
}
/**
* 개발 환경에서 components.json 은 no-cache (파일 수정 즉시 반영 — F10)
*
* @scenario publish_state=unpublished, environment=dev, trigger=self_heal, process_user=web
*
* @effects fallback_api_no_cache_in_dev
*/
public function test_components_json_no_cache_in_development(): void
{
// Arrange
app()['env'] = 'local';
$componentsPath = base_path("templates/{$this->testIdentifier}/components.json");
file_put_contents($componentsPath, json_encode([
'Button' => ['path' => 'components/Button.jsx'],
]));
// Act
$response = $this->get($this->templateUrl('components.json'));
// Assert
$response->assertStatus(200);
$cacheControl = (string) $response->headers->get('Cache-Control');
$this->assertStringContainsString('no-cache', $cacheControl);
$this->assertStringNotContainsString('max-age=3600', $cacheControl);
}
/** /**
* 비활성화 템플릿의 components.json 접근 시 404 반환 * 비활성화 템플릿의 components.json 접근 시 404 반환
*/ */
@@ -155,6 +155,9 @@ class TemplateLanguageServingTest extends TestCase
*/ */
public function test_cache_control_header_is_set(): void public function test_cache_control_header_is_set(): void
{ {
// public max-age 는 프로덕션 정책 (dev 는 no-cache 환경 분기, #122 작업 C)
app()['env'] = 'production';
$response = $this->getJson('/api/templates/sirsoft-admin_basic/lang/ko.json'); $response = $this->getJson('/api/templates/sirsoft-admin_basic/lang/ko.json');
$response->assertStatus(200); $response->assertStatus(200);
@@ -166,6 +169,44 @@ class TemplateLanguageServingTest extends TestCase
$this->assertStringContainsString('public', $cacheControl); $this->assertStringContainsString('public', $cacheControl);
} }
/**
* lang If-None-Match 일치 시 304 (조건부 캐시 — #122 작업 C)
*/
public function test_language_returns_304_with_matching_etag(): void
{
app()['env'] = 'production';
$first = $this->getJson('/api/templates/sirsoft-admin_basic/lang/ko.json');
$first->assertStatus(200);
$etag = $first->headers->get('ETag');
$this->assertNotNull($etag);
$second = $this->getJson(
'/api/templates/sirsoft-admin_basic/lang/ko.json',
['If-None-Match' => $etag]
);
$second->assertStatus(304);
$this->assertSame('', $second->getContent());
}
/**
* 개발 환경에서 lang 은 no-cache (파일 수정 즉시 반영 — F10)
*
* @scenario publish_state=unpublished, environment=dev, trigger=manual_command, process_user=web
*/
public function test_language_no_cache_in_development(): void
{
app()['env'] = 'local';
$response = $this->getJson('/api/templates/sirsoft-admin_basic/lang/ko.json');
$response->assertStatus(200);
$cacheControl = (string) $response->headers->get('Cache-Control');
$this->assertStringContainsString('no-cache', $cacheControl);
$this->assertStringNotContainsString('max-age=3600', $cacheControl);
}
/** /**
* 잘못된 로케일 형식 요청 시 404 반환 * 잘못된 로케일 형식 요청 시 404 반환
*/ */
@@ -0,0 +1,120 @@
/**
* Smoke: stale 캐시버전 재방문의 부트 이중 로드 제거 (#122 작업 A·B)
*
* localStorage `g7_cache_version` 이 stale 이면 첫 burst 가 구버전 `?v` 로 나가고,
* config 핸드셰이크가 routes 재로드 + lang(ko·en, `_=` 버스터) 재다운로드를 유발해
* ~500KB 중복 / 부트 ~1.3s 연장이 발생했다. blade 주입 cache_version 시드가
* 이를 제거했음을 실브라우저에서 잠근다.
*
* @scenario publish_state=published, environment=production, trigger=lifecycle, process_user=web
* @effects no_duplicate_boot_requests, versioned_boot_urls
*/
import { test, expect, type Page } from '@playwright/test';
/**
* 부트 리소스 URL 판별 — 정적 게시 경로와 API 경로(이중 모드 포함)를 모두 흡수한다.
*
* 확장자 형태만 매칭하면 정적 게시가 켜진 사이트에서 카운터가 0 이 되어
* 테스트가 조용히 무의미해진다 (network-resilience.spec.ts 의 suffixedPath 확장).
*/
function bootResourceMatchers(pathname: string): { routes: boolean; lang: string | null; components: boolean } {
const routes =
/^\/build\/ext\/\d+\/templates\/[^/]+\/routes\.json$/.test(pathname) ||
/^\/api\/templates\/[^/]+\/routes(\.json)?$/.test(pathname);
const staticLang = pathname.match(/^\/build\/ext\/\d+\/templates\/[^/]+\/lang\/([a-z-]+)\.json$/);
const apiLang = pathname.match(/^\/api\/templates\/[^/]+\/lang\/([a-z-]+)(\.json)?$/);
const lang = staticLang?.[1] ?? apiLang?.[1] ?? null;
const components =
/^\/build\/ext\/\d+\/templates\/[^/]+\/components\.json$/.test(pathname) ||
/^\/api\/templates\/[^/]+\/components(\.json)?$/.test(pathname);
return { routes, lang, components };
}
/** 페이지의 부트 리소스 요청을 수집한다 */
function collectBootRequests(page: Page) {
const routesUrls: string[] = [];
const langUrls = new Map<string, string[]>();
const componentUrls: string[] = [];
const busterUrls: string[] = [];
page.on('request', (request) => {
const url = new URL(request.url());
const { routes, lang, components } = bootResourceMatchers(url.pathname);
if (routes) routesUrls.push(request.url());
if (lang) langUrls.set(lang, [...(langUrls.get(lang) ?? []), request.url()]);
if (components) componentUrls.push(request.url());
if (/[?&]_=\d+/.test(url.search)) busterUrls.push(request.url());
});
return { routesUrls, langUrls, componentUrls, busterUrls };
}
test.describe('부트 이중 로드 제거 (#122)', () => {
test('@smoke stale localStorage 재방문 — routes 1회·lang 로케일당 1회·`_=` 0건·버전드 URL', async ({ page }) => {
// stale 캐시버전 시드 (재방문자 시뮬레이션 — 종전엔 이중 로드 트리거)
await page.addInitScript(() => {
try {
window.localStorage.setItem('g7_cache_version', '1');
} catch {
/* localStorage 불가 환경은 시드 없이 진행 */
}
});
const collected = collectBootRequests(page);
await page.goto('/');
await page.waitForFunction(
() => (document.querySelector('#app')?.childElementCount ?? 0) > 0,
{ timeout: 30_000 }
);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
// routes 는 정확히 1회 (핸드셰이크 재로드 부재)
expect(collected.routesUrls, `routes 요청: ${collected.routesUrls.join(', ')}`).toHaveLength(1);
// lang 은 로케일당 1회
for (const [locale, urls] of collected.langUrls) {
expect(urls, `lang/${locale} 요청: ${urls.join(', ')}`).toHaveLength(1);
}
// `_=` 캐시 버스터 재로드 0건
expect(collected.busterUrls, `버스터 요청: ${collected.busterUrls.join(', ')}`).toHaveLength(0);
// 부트 리소스 URL 은 버전 기반 (정적 경로 `/build/ext/{v}/` 또는 `?v=`)
const bootUrls = [...collected.routesUrls, ...collected.componentUrls];
for (const url of bootUrls) {
expect(
/\/build\/ext\/\d+\//.test(url) || /[?&]v=\d+/.test(url),
`버전 없는 부트 URL: ${url}`
).toBe(true);
}
// localStorage 는 서버 버전으로 치유된다
const healed = await page.evaluate(() => ({
stored: window.localStorage.getItem('g7_cache_version'),
injected: (window as any).G7Config?.cache_version,
}));
expect(healed.stored).toBe(String(healed.injected));
});
test('@smoke 치유 후 재로드 — 요청 구성이 동일하고 이중 로드가 없다', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle', { timeout: 30_000 });
const collected = collectBootRequests(page);
await page.reload();
await page.waitForFunction(
() => (document.querySelector('#app')?.childElementCount ?? 0) > 0,
{ timeout: 30_000 }
);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
expect(collected.routesUrls.length).toBeLessThanOrEqual(1);
expect(collected.busterUrls).toHaveLength(0);
});
});
@@ -71,6 +71,20 @@ async function bodyText(page: Page): Promise<string> {
return page.evaluate(() => document.body.innerText.trim().replace(/\n+/g, ' | ')); return page.evaluate(() => document.body.innerText.trim().replace(/\n+/g, ' | '));
} }
/**
* 정적 게시(bake, #122) fast path 강제 미스.
*
* 정적 게시가 완료된 프로덕션 사이트에서는 routes/components/lang/번들이
* `/build/ext/{v}/…` 를 먼저 타므로, `/api/**` 패턴 인터셉트가 한 건도 걸리지
* 않은 채 이 스펙 전체가 무력화된다 (suffixedPath 3형태 밖의 4번째 URL 형태).
* 이 스펙의 검증 대상은 **API 폴백 경로의 복원력**이므로 정적 요청을 404 로
* 강제해 앱 자체 폴백(fetchStaticFirst / staticToLegacy)이 API 계층으로
* 내려보내게 한다. 미게시 사이트에서는 `/build/ext/**` 요청이 없어 무해하다.
*/
test.beforeEach(async ({ page }) => {
await page.route('**/build/ext/**', (route) => route.fulfill({ status: 404, body: '' }));
});
test.describe('네트워크 복원력 — 요청 1건의 일시 실패 (#463)', () => { test.describe('네트워크 복원력 — 요청 1건의 일시 실패 (#463)', () => {
// 각 경로의 첫 요청 1건을 취소해도 재시도로 복구되어 앱이 정상 렌더돼야 한다. // 각 경로의 첫 요청 1건을 취소해도 재시도로 복구되어 앱이 정상 렌더돼야 한다.
const SINGLE_ABORT_PATHS: Array<{ name: string; pattern: string | ((url: URL) => boolean) }> = [ const SINGLE_ABORT_PATHS: Array<{ name: string; pattern: string | ((url: URL) => boolean) }> = [
@@ -220,6 +234,19 @@ test.describe('네트워크 복원력 — 번들이 끝내 부재할 때 (#463)'
}, },
); );
// 게시(bake) 사이트에서는 blade 가 이 번들을 정적 URL 로 방출하고, 그 1회가
// MAX_ATTEMPTS 예산의 첫 슬롯을 소비한다 (L2 — 예산 신설 금지, staticToLegacy 는
// 남은 예산으로 API 형태에 합류). 정적 시도를 따로 세어 총 예산으로 단언한다.
let staticAttempts = 0;
await page.route(
(url) => /\/build\/ext\/.*components\.iife\.js/.test(url.pathname),
(route) => {
staticAttempts += 1;
return route.fulfill({ status: 404, body: '' });
},
);
await page.goto('/'); await page.goto('/');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await page.waitForFunction( await page.waitForFunction(
@@ -234,8 +261,9 @@ test.describe('네트워크 복원력 — 번들이 끝내 부재할 때 (#463)'
expect(text).toMatch(FALLBACK_UI); expect(text).toMatch(FALLBACK_UI);
await expect(page.locator('#app button')).toBeVisible(); await expect(page.locator('#app button')).toBeVisible();
// 재시도 3시도 (초기 1 + 재시도 2) // 총 3시도 (초기 1 + 재시도 2) — 게시 사이트는 정적 1 + API 2, 미게시는 API 3.
expect(attempts).toBe(3); // 합산 정확값으로 잠가 과잉 재시도(예산 신설) 회귀를 양쪽 세계에서 차단한다.
expect(attempts + staticAttempts).toBe(3);
}); });
/** /**
@@ -0,0 +1,112 @@
/**
* Smoke: 정적 게시(bake) fast path + API 폴백 (#122 S2·S3)
*
* 정적 게시가 켜진 프로덕션 사이트에서 ① 부트 리소스가 `/build/ext/{v}/…` 로
* 수신되고 ② 정적 응답을 브라우저단에서 404 로 강제해도 legacy API 폴백으로
* 화면이 정상 렌더됨을 잠근다. 서버 파일 조작이 없으므로 원복이 불필요하다.
*
* env 가드 — 대상 사이트가 정적 게시 미적용(비프로덕션/kill-switch/미게시)이면
* staticBase 미주입으로 skip 된다 (첫 방문이 자가 치유를 예약하므로 워밍 1회 수행).
*
* @scenario publish_state=partial, environment=production, trigger=self_heal, process_user=web
* @effects static_first_fetch_falls_back_to_api_on_miss, fallback_is_observable_via_console_warn
*/
import { test, expect, type Page } from '@playwright/test';
/** 대상 사이트의 staticBase 주입 여부 (자가 치유 워밍 포함) */
async function probeStaticBase(page: Page): Promise<string | null> {
await page.goto('/');
await page.waitForLoadState('networkidle', { timeout: 30_000 });
let base = await page.evaluate(() => (window as any).G7Config?.staticBase ?? null);
if (!base) {
// 미게시 상태였다면 방금 렌더가 terminating 게시를 예약했다 — 1회 재방문
await page.waitForTimeout(3_000);
await page.reload();
await page.waitForLoadState('networkidle', { timeout: 30_000 });
base = await page.evaluate(() => (window as any).G7Config?.staticBase ?? null);
}
return typeof base === 'string' ? base : null;
}
test.describe('정적 게시 fast path + 폴백 (#122)', () => {
/**
* @scenario publish_state=published, environment=production, trigger=self_heal, process_user=web
* @effects versioned_boot_urls
*/
test('@smoke 정적 URL 로 부트 리소스를 수신한다 (static-first)', async ({ page }) => {
const staticUrls: string[] = [];
page.on('request', (request) => {
if (new URL(request.url()).pathname.startsWith('/build/ext/')) {
staticUrls.push(request.url());
}
});
const base = await probeStaticBase(page);
test.skip(base === null, '대상 사이트에 정적 게시 미적용 (staticBase 미주입)');
await page.reload();
await page.waitForFunction(
() => (document.querySelector('#app')?.childElementCount ?? 0) > 0,
{ timeout: 30_000 }
);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
// 정적 게시 경로에서 부트 리소스를 실제로 받는다
expect(staticUrls.length, `정적 경로 수신: ${staticUrls.join(', ')}`).toBeGreaterThan(0);
expect(staticUrls.some((u) => /\/templates\/[^/]+\/routes\.json/.test(u))).toBe(true);
});
test('@smoke 정적 응답을 404 로 강제해도 legacy API 폴백으로 화면이 정상 렌더된다', async ({ page }) => {
const base = await probeStaticBase(page);
test.skip(base === null, '대상 사이트에 정적 게시 미적용 (staticBase 미주입)');
const warns: string[] = [];
page.on('console', (message) => {
if (message.type() === 'warning') warns.push(message.text());
});
let aborted = 0;
const apiFallbacks: string[] = [];
// 정적 fetch 리소스(routes/lang/components)만 404 강제 — 태그 자산(CSS/JS)은
// 파일 게이트가 이미 통과시킨 실파일이므로 건드리지 않는다 (렌더 자체를 위한 것)
await page.route(
(url) => /\/build\/ext\/\d+\/templates\/[^/]+\/(routes|components|lang\/[a-z-]+)\.json$/.test(url.pathname),
(route) => {
aborted += 1;
return route.fulfill({ status: 404, body: 'forced miss' });
}
);
page.on('request', (request) => {
const path = new URL(request.url()).pathname;
if (/^\/api\/templates\/[^/]+\/(routes|components|lang\/[a-z-]+)(\.json)?$/.test(path)) {
apiFallbacks.push(request.url());
}
});
await page.reload();
await page.waitForFunction(
() => (document.querySelector('#app')?.childElementCount ?? 0) > 0,
{ timeout: 30_000 }
);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
// 정적 미스가 실제로 발생했고
expect(aborted).toBeGreaterThan(0);
// legacy API 폴백이 그 자리를 메웠으며
expect(apiFallbacks.length, `API 폴백: ${apiFallbacks.join(', ')}`).toBeGreaterThan(0);
// 화면은 전면 에러 없이 정상이다
const text = await page.evaluate(() => document.body.innerText.trim());
expect(text).not.toMatch(/초기화 실패|페이지 로딩 실패/);
expect(text.length).toBeGreaterThan(20);
// 폴백은 조용하지 않다 — console warn 으로 관측 가능
expect(warns.some((w) => w.includes('fetchStaticFirst')), `warns: ${warns.join(' | ')}`).toBe(true);
});
});
@@ -0,0 +1,132 @@
<?php
namespace Tests\Unit\Cache;
use App\Extension\Cache\CoreCacheDriver;
use Illuminate\Contracts\Cache\Store;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
/**
* 캐시 쓰기 실패는 페이지를 죽이면 안 된다 (fail-soft).
*
* 실사례 (7.0.9→7.0.10 sudo 업데이트): root 로 실행된 업데이트가 캐시 키 인덱스
* 파일(`g7:_idx:g7:core` — 모든 remember 가 갱신)을 root 소유로 남기면, 이후 웹
* 프로세스의 **모든** 캐시 쓰기가 fopen Permission denied 예외로 죽어 부팅 경로
* 전면 500 이 된다 (로거 도달 전이라 laravel.log 무기록). 캐시는 최적화일 뿐이므로
* 쓰기 실패는 경고 로그 + 무캐시 동작으로 강등되어야 한다.
*/
class CacheDriverWriteFailSoftTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config(['cache.stores.g7_failing_store' => ['driver' => 'g7_failing_store']]);
Cache::extend('g7_failing_store', function () {
return Cache::repository(new class implements Store
{
public array $data = [];
public function get($key)
{
return $this->data[$key] ?? null;
}
public function many(array $keys)
{
return array_map(fn ($k) => $this->get($k), array_combine($keys, $keys));
}
public function put($key, $value, $seconds)
{
throw new \RuntimeException("fopen({$key}): Failed to open stream: Permission denied");
}
public function putMany(array $values, $seconds)
{
throw new \RuntimeException('putMany: Permission denied');
}
public function increment($key, $value = 1)
{
throw new \RuntimeException('increment: Permission denied');
}
public function decrement($key, $value = 1)
{
throw new \RuntimeException('decrement: Permission denied');
}
public function forever($key, $value)
{
throw new \RuntimeException('forever: Permission denied');
}
public function forget($key)
{
throw new \RuntimeException('forget: Permission denied');
}
public function flush()
{
throw new \RuntimeException('flush: Permission denied');
}
public function getPrefix()
{
return '';
}
});
});
}
private function driver(): CoreCacheDriver
{
return new CoreCacheDriver('g7_failing_store');
}
/**
* put 실패는 예외를 전파하지 않고 false 를 반환한다.
*/
public function test_put_write_failure_returns_false_without_throwing(): void
{
$this->assertFalse($this->driver()->put('some.key', 'value'));
}
/**
* remember 는 저장(put/인덱스 기록)이 실패해도 콜백 결과를 반환한다 —
* 부팅 경로의 remember 하나가 죽으면 화면 전체가 500 이 되기 때문.
* 콜백은 정확히 1회만 실행되어야 한다 (재실행 부수효과 금지).
*/
public function test_remember_returns_callback_value_when_write_fails(): void
{
$calls = 0;
$value = $this->driver()->remember('boot.key', function () use (&$calls) {
$calls++;
return ['ok' => true];
});
$this->assertSame(['ok' => true], $value);
$this->assertSame(1, $calls);
}
/**
* forget 실패도 예외를 전파하지 않는다.
*/
public function test_forget_write_failure_returns_false_without_throwing(): void
{
$this->assertFalse($this->driver()->forget('some.key'));
}
/**
* putMany 실패도 예외를 전파하지 않는다.
*/
public function test_put_many_write_failure_returns_false_without_throwing(): void
{
$this->assertFalse($this->driver()->putMany(['a' => 1, 'b' => 2]));
}
}
+19 -1
View File
@@ -56,6 +56,9 @@ class SeoNodeKeyParityTest extends TestCase
'id', 'id',
'key', 'key',
'comment', 'comment',
// `_comment` 는 접두사 계열이다 — 저장소 관례상 `_comment_id`·`_comment_hidden` 등
// 문맥 이름을 붙인 변형이 통용된다(전수 8종). 정확일치로만 등록하면 새 변형마다
// 이 테스트가 red 가 되므로 isCommentKey() 가 접두사로 판정한다.
'_comment', '_comment',
// 설치 시점에 인라인 확장되므로 SEO 렌더 시에는 남지 않는다 // 설치 시점에 인라인 확장되므로 SEO 렌더 시에는 남지 않는다
'partial', 'partial',
@@ -110,7 +113,7 @@ class SeoNodeKeyParityTest extends TestCase
} }
foreach ($this->collectNodeKeys($decoded) as $key) { foreach ($this->collectNodeKeys($decoded) as $key) {
if (! in_array($key, $known, true)) { if (! in_array($key, $known, true) && ! $this->isCommentKey($key)) {
$unknown[$key][] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $file); $unknown[$key][] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $file);
} }
} }
@@ -124,6 +127,21 @@ class SeoNodeKeyParityTest extends TestCase
)); ));
} }
/**
* `_comment` 접두사 주석 키인지 판정합니다.
*
* 저장소 관례상 주석 키는 `_comment` 단독 외에 `_comment_id` 처럼 설명 대상을
* 접미사로 붙인 변형이 통용된다. React(DynamicRenderer)와 SEO(ComponentHtmlMapper)
* 모두 알 수 없는 최상위 키를 무시하므로 렌더 패리티에 영향이 없다.
*
* @param string $key 노드 최상위 키
* @return bool 주석 키 여부
*/
private function isCommentKey(string $key): bool
{
return str_starts_with($key, '_comment');
}
/** /**
* 문서 루트가 곧 노드인 partial 파일도 실제로 스캔됩니다. * 문서 루트가 곧 노드인 partial 파일도 실제로 스캔됩니다.
* *
@@ -0,0 +1,78 @@
<?php
namespace Tests\Unit\Seo;
use App\Seo\SeoRenderer;
use App\Services\ExtensionStaticCacheService;
use App\Support\AssetUrl;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
/**
* SEO 봇 HTML 의 템플릿 CSS URL 은 정적 게시(bake) 경로를 우회해야 한다 (#122).
*
* 봇 HTML 은 SeoCacheManager(`seo.page.*` — 키에 cache_version 미포함, TTL 수시간)에
* 캐시되는데, 정적 게시 디렉토리는 GC 가 현재+직전 1개만 보존한다. 캐시된 HTML 이
* GC 된 `/build/ext/{v}/…` 를 참조하면 봇 화면의 CSS 가 404 이고, SEO HTML 에는
* asset-url-recovery 파샬이 포함되지 않아 자가 복구 경로도 없다. 따라서 SEO 렌더는
* 항상 무버전 API URL(정적 게이트 우회 = 종전 동작)을 사용한다.
*/
class SeoRendererStaticAssetBypassTest extends TestCase
{
private const VERSION = 424242;
/** 테스트 전용 public 루트 (실 게시 트리 격리) */
private string $isolatedPublicPath;
protected function setUp(): void
{
parent::setUp();
// 실 게시 트리(public/build/ext) 오염 방지 (ExtensionStaticCacheServiceTest 와 동일 근거)
$this->isolatedPublicPath = storage_path('framework/testing/public-seobypass-'.getmypid());
File::ensureDirectoryExists($this->isolatedPublicPath);
$this->app->usePublicPath($this->isolatedPublicPath);
}
protected function tearDown(): void
{
File::deleteDirectory($this->isolatedPublicPath);
AssetUrl::resetStaticExtBaseMemo();
ExtensionStaticCacheService::resetPublishScheduleForTesting();
parent::tearDown();
}
/**
* 정적 게이트가 열린 상태에서도 SEO CSS URL 은 `/build/ext/` 를 참조하지 않는다.
*
* @effects seo_html_never_references_gc_target_static_path
*/
public function test_seo_css_url_은_정적_게시_경로를_우회한다(): void
{
Cache::put('g7:core:ext.cache_version', self::VERSION);
app()['env'] = 'production';
$dir = public_path('build/ext/'.self::VERSION);
File::ensureDirectoryExists($dir.'/templates/sirsoft-basic/assets/css');
File::put($dir.'/manifest.json', '{}');
File::put($dir.'/templates/sirsoft-basic/assets/css/components.css', 'x');
AssetUrl::resetStaticExtBaseMemo();
// 전제 고정 — 블레이드 경로(기본값)는 정적 게이트를 통과하는 상태다.
// 이 단언이 깨지면 아래 우회 단언은 게이트 미통과로 인한 공허한 통과가 된다.
$this->assertSame(
'/build/ext/'.self::VERSION.'/templates/sirsoft-basic/assets/css/components.css',
AssetUrl::templateAsset('sirsoft-basic', 'css/components.css')
);
$method = new \ReflectionMethod(SeoRenderer::class, 'getTemplateCssUrls');
$urls = $method->invoke(app(SeoRenderer::class), 'sirsoft-basic');
$this->assertNotEmpty($urls);
foreach ($urls as $url) {
$this->assertStringNotContainsString('/build/ext/', $url);
}
}
}
+178
View File
@@ -0,0 +1,178 @@
<?php
namespace Tests\Unit\Seo;
use App\Seo\ComponentHtmlMapper;
use App\Seo\ExpressionEvaluator;
use Tests\TestCase;
/**
* props 값의 `$switch`/`$cases` 객체 해석이 React 렌더러와 같은지 감시합니다.
*
* 배경 (engine-v1.56.0 패리티):
* React `DataBindingEngine.resolveObject` 는 `{ "$switch": "{{expr}}", "$cases": {...},
* "$default": ... }` 형태의 props 값을 선언적 분기로 해석한다 (일반 경로 + 반복 경로).
* 봇(SEO) 렌더러가 이를 모르면 그 prop 은 배열이라는 이유로 **속성 자체가 조용히
* 사라진다** — 예외도 경고도 없다. 레이아웃 최상위 `computed` 의 `$switch` 는
* `SeoRenderer::resolveComputedSwitch` 가 별도로 처리하며, 이 테스트는 **노드 props
* 값** 축을 고정한다.
*
* 범위: props(속성) 축. 노드 `text` 키는 React 도 문자열만 해석하므로 대상이 아니다.
*
* @see ComponentHtmlMapper::buildAttributes()
*/
class SeoSwitchValueParityTest extends TestCase
{
private ComponentHtmlMapper $mapper;
private ExpressionEvaluator $evaluator;
protected function setUp(): void
{
parent::setUp();
$this->mapper = new ComponentHtmlMapper;
$this->evaluator = new ExpressionEvaluator;
$this->mapper->setComponentMap([
'Div' => ['tag' => 'div'],
'Li' => ['tag' => 'li'],
]);
$this->mapper->setTextProps(['text']);
$this->mapper->setAllowedAttrs(['class', 'id']);
$this->mapper->setAttrMap(['className' => 'class']);
}
/**
* 컴포넌트 배열을 렌더링합니다.
*
* @param array $components 컴포넌트 정의 배열
* @param array $context 데이터 컨텍스트
* @return string 렌더링된 HTML
*/
private function render(array $components, array $context = []): string
{
return $this->mapper->render($components, $context, $this->evaluator);
}
/**
* 일반 경로 — props 의 $switch 객체가 case 값으로 해석됩니다.
*
* @effects switch_prop_values_resolved_in_bot_render
*/
public function test_general_path_switch_prop_resolves_to_case_value(): void
{
$html = $this->render([
[
'name' => 'Div',
'props' => [
'className' => [
'$switch' => '{{tab}}',
'$cases' => ['products' => 'text-red', 'contents' => 'text-blue'],
'$default' => 'text-gray',
],
],
'text' => 'x',
],
], ['tab' => 'products']);
$this->assertStringContainsString('class="text-red"', $html);
}
/**
* 반복 경로 — 항목 변수를 참조하는 $switch 키가 항목마다 해석됩니다.
*
* @effects switch_prop_values_resolved_in_bot_render
*/
public function test_iteration_path_switch_key_resolves_per_item(): void
{
$html = $this->render([
[
'name' => 'Li',
'iteration' => [
'data' => '{{rows}}',
'item_var' => 'row',
],
'props' => [
'className' => [
'$switch' => '{{row.kind}}',
'$cases' => ['a' => 'kind-a', 'b' => 'kind-b'],
],
],
'text' => '{{row.kind}}',
],
], ['rows' => [['kind' => 'a'], ['kind' => 'b']]]);
$this->assertStringContainsString('class="kind-a"', $html);
$this->assertStringContainsString('class="kind-b"', $html);
}
/**
* 매칭 실패 시 $default 로 폴백합니다.
*
* @effects switch_prop_values_resolved_in_bot_render
*/
public function test_switch_falls_back_to_default_when_no_case_matches(): void
{
$html = $this->render([
[
'name' => 'Div',
'props' => [
'className' => [
'$switch' => '{{tab}}',
'$cases' => ['products' => 'text-red'],
'$default' => 'text-gray',
],
],
'text' => 'x',
],
], ['tab' => 'unknown']);
$this->assertStringContainsString('class="text-gray"', $html);
}
/**
* 매칭 실패 + $default 부재 → 속성이 방출되지 않습니다 (React undefined 와 동일).
*/
public function test_switch_without_match_or_default_emits_no_attribute(): void
{
$html = $this->render([
[
'name' => 'Div',
'props' => [
'className' => [
'$switch' => '{{tab}}',
'$cases' => ['products' => 'text-red'],
],
],
'text' => 'x',
],
], ['tab' => 'unknown']);
$this->assertStringNotContainsString('class=', $html);
$this->assertStringContainsString('x', $html);
}
/**
* case 값의 `{{}}` 바인딩도 해석됩니다 (React 중첩 표현식 지원과 동일).
*
* @effects switch_prop_values_resolved_in_bot_render
*/
public function test_switch_case_value_bindings_are_resolved(): void
{
$html = $this->render([
[
'name' => 'Div',
'props' => [
'className' => [
'$switch' => '{{tab}}',
'$cases' => ['products' => 'badge-{{level}}'],
],
],
'text' => 'x',
],
], ['tab' => 'products', 'level' => 'gold']);
$this->assertStringContainsString('class="badge-gold"', $html);
}
}
@@ -0,0 +1,55 @@
<?php
namespace Tests\Unit\Services;
use App\Services\CoreUpdateService;
use Tests\TestCase;
/**
* 업데이트 종료 시 root 산출물 소유권 정상화 (실사례: 7.0.9→7.0.10 전면 500).
*
* sudo 업데이트가 캐시 키 인덱스·상태 캐시·병합 번들을 root 소유로 남기면
* 웹 프로세스의 캐시 쓰기가 Permission denied 로 죽는다. 업데이트 흐름의
* 모든 쓰기가 끝난 종료 지점에서 런타임 디렉토리를 기준 소유자(디렉토리
* 자신의 소유자)로 재귀 정상화해 root 산출물이 남지 않게 한다.
*/
class CoreUpdateRuntimeOwnershipTest extends TestCase
{
/**
* 비-root 프로세스에서는 no-op 이다 (chown 자체가 불가능하고 불필요).
*/
public function test_non_root_process_is_noop(): void
{
$service = app(CoreUpdateService::class);
// 예외 없이 종료해야 한다 (Windows/비-root: posix 가드로 즉시 반환)
$service->normalizeRuntimeOwnershipAfterRootRun();
$this->assertTrue(true);
}
/**
* 부모(CoreUpdateCommand)·자식(ExecuteUpgradeStepsCommand) 흐름 종료부가
* 정상화를 호출한다 — 로그 소유권 정상화(restoreUpgradeLogOwnership)와
* 항상 짝으로 (#519 소스 훑기 선례).
*/
public function test_update_flows_invoke_runtime_ownership_normalization(): void
{
foreach ([
app_path('Console/Commands/Core/CoreUpdateCommand.php'),
app_path('Console/Commands/Core/ExecuteUpgradeStepsCommand.php'),
] as $file) {
$source = (string) file_get_contents($file);
$logCalls = substr_count($source, '$this->restoreUpgradeLogOwnership();');
$normalizeCalls = substr_count($source, 'normalizeRuntimeOwnershipAfterRootRun');
$this->assertGreaterThan(0, $logCalls, basename($file).': 로그 소유권 정상화 호출이 사라졌습니다');
$this->assertGreaterThanOrEqual(
$logCalls,
$normalizeCalls,
basename($file).': 흐름 종료부(restoreUpgradeLogOwnership 호출 지점)마다 런타임 소유권 정상화가 짝으로 호출되어야 합니다'
);
}
}
}
@@ -1293,6 +1293,48 @@ MD;
} }
} }
/**
* `applyUpdate(prune:true)` 가 `public` 타깃을 정리할 때 정적 게시본
* (`public/build/ext`)을 orphan 으로 삭제하지 않고 보존합니다 (#122).
*
* 게시본은 릴리즈 소스에 없는 로컬 파생물이라 excludes(`build/ext`) 미등록 시
* prune 이 삭제해, 업데이트 완료까지 정적 fast path 가 불필요하게 끊긴다.
*/
public function test_apply_update_prune_preserves_static_publish_dir(): void
{
[$source, $fakeBase, $restore] = $this->prepareApplyUpdateEnv(['public']);
// prepareApplyUpdateEnv 가 excludes 를 비우므로 실제 기본값(config/app.php)을 재주입 —
// 이 테스트의 검증 대상이 바로 excludes 의 `build/ext` 항목이다
config(['app.update.excludes' => ['node_modules', '.git', 'bootstrap/cache', 'build/ext']]);
try {
// source(릴리즈): public/build/core 는 추적 배포 산출물이라 항상 존재,
// build/ext 는 로컬 파생물이라 릴리즈 소스에 없다
File::ensureDirectoryExists($source.DIRECTORY_SEPARATOR.'public'.DIRECTORY_SEPARATOR.'build'.DIRECTORY_SEPARATOR.'core');
File::put($source.DIRECTORY_SEPARATOR.'public'.DIRECTORY_SEPARATOR.'index.php', "<?php // core\n");
File::put(
$source.DIRECTORY_SEPARATOR.'public'.DIRECTORY_SEPARATOR.'build'.DIRECTORY_SEPARATOR.'core'.DIRECTORY_SEPARATOR.'template-engine.min.js',
'// bundle'
);
// 활성(mine): 정적 게시본 + manifest
$publishDir = $fakeBase.DIRECTORY_SEPARATOR.'public'.DIRECTORY_SEPARATOR.'build'
.DIRECTORY_SEPARATOR.'ext'.DIRECTORY_SEPARATOR.'1234';
File::ensureDirectoryExists($publishDir);
File::put($publishDir.DIRECTORY_SEPARATOR.'manifest.json', '{}');
$this->service->applyUpdate($source, null, prune: true, applyList: null);
$this->assertFileExists(
$publishDir.DIRECTORY_SEPARATOR.'manifest.json',
'prune 업데이트가 정적 게시본(public/build/ext)을 orphan 삭제하면 안 된다',
);
} finally {
$restore();
}
}
/** /**
* end-to-end 회귀 (#43): `applyUpdate(prune:true)` 가 `public` 타깃을 정리할 때 * end-to-end 회귀 (#43): `applyUpdate(prune:true)` 가 `public` 타깃을 정리할 때
* `public/storage` symlink 를 orphan 으로 삭제하지 않고 보존합니다. * `public/storage` symlink 를 orphan 으로 삭제하지 않고 보존합니다.
+214
View File
@@ -2,7 +2,10 @@
namespace Tests\Unit\Support; namespace Tests\Unit\Support;
use App\Services\ExtensionStaticCacheService;
use App\Support\AssetUrl; use App\Support\AssetUrl;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Tests\TestCase; use Tests\TestCase;
/** /**
@@ -14,9 +17,25 @@ use Tests\TestCase;
*/ */
class AssetUrlTest extends TestCase class AssetUrlTest extends TestCase
{ {
/** 테스트 전용 public 루트 (실 게시 트리 격리) */
private string $isolatedPublicPath;
protected function setUp(): void
{
parent::setUp();
// 실 게시 트리(public/build/ext) 격리 — cleanupStaticFixture 가 base 를 통째로
// 지우므로, 격리 없이는 단위 테스트 1회 실행이 운영 중 사이트의 게시본을 전량
// 삭제한다 (ExtensionStaticCacheServiceTest 의 격리와 동일 근거).
$this->isolatedPublicPath = storage_path('framework/testing/public-asseturl-'.getmypid());
File::ensureDirectoryExists($this->isolatedPublicPath);
$this->app->usePublicPath($this->isolatedPublicPath);
}
protected function tearDown(): void protected function tearDown(): void
{ {
AssetUrl::forceMode(null); AssetUrl::forceMode(null);
File::deleteDirectory($this->isolatedPublicPath);
parent::tearDown(); parent::tearDown();
} }
@@ -172,4 +191,199 @@ class AssetUrlTest extends TestCase
$this->assertSame(AssetUrl::MODE_EXTENSION, AssetUrl::mode()); $this->assertSame(AssetUrl::MODE_EXTENSION, AssetUrl::mode());
} }
// ── 정적 게시(bake) 게이트 (#122 S2) ─────────────────────────────────
/**
* 정적 게이트용 게시 픽스처를 만든다.
*
* @param int $version 버전
* @param array<string> $files 버전 디렉토리 내 상대 경로 목록
*/
private function publishFixture(int $version, array $files = []): void
{
$dir = public_path('build/ext/'.$version);
File::ensureDirectoryExists($dir);
File::put($dir.'/manifest.json', '{}');
foreach ($files as $relative) {
File::ensureDirectoryExists(dirname($dir.'/'.$relative));
File::put($dir.'/'.$relative, 'x');
}
}
private function cleanupStaticFixture(): void
{
File::deleteDirectory(public_path('build/ext'));
AssetUrl::resetStaticExtBaseMemo();
ExtensionStaticCacheService::resetPublishScheduleForTesting();
}
/**
* 정적 게이트 3조건 — 프로덕션 + enabled + 게시 완료(manifest) 를 전부
* 통과해야만 base 가 반환된다.
*
* @scenario publish_state=unpublished, environment=production, trigger=self_heal, process_user=web
*
* @effects static_gate_requires_manifest, kill_switch_disables_publish_and_gate
*/
public function test_정적_게이트는_프로덕션_활성_게시완료_3조건이다(): void
{
try {
Cache::put('g7:core:ext.cache_version', 424242);
// 비프로덕션(testing) → null
AssetUrl::resetStaticExtBaseMemo();
$this->publishFixture(424242);
$this->assertNull(AssetUrl::staticExtBase());
// 프로덕션 + 게시 완료 → base
app()['env'] = 'production';
AssetUrl::resetStaticExtBaseMemo();
$this->assertSame('/build/ext/424242', AssetUrl::staticExtBase());
// kill-switch off → null
config(['core.static_cache.enabled' => false]);
AssetUrl::resetStaticExtBaseMemo();
$this->assertNull(AssetUrl::staticExtBase());
config(['core.static_cache.enabled' => true]);
// manifest 부재(미게시) → null
File::delete(public_path('build/ext/424242/manifest.json'));
AssetUrl::resetStaticExtBaseMemo();
$this->assertNull(AssetUrl::staticExtBase());
} finally {
$this->cleanupStaticFixture();
}
}
/**
* 태그 계층 파일 단위 게이트 — manifest 는 있어도 그 자산의 실파일이 없으면
* 그 자산만 종전 API URL 로 방출된다 (나머지는 정적 URL).
*
* @scenario publish_state=partial, environment=production, trigger=manual_command, process_user=web
*
* @effects tag_layer_checks_individual_file_existence
*/
public function test_태그_계층은_개별_파일_존재까지_확인한다(): void
{
try {
Cache::put('g7:core:ext.cache_version', 424242);
app()['env'] = 'production';
$this->publishFixture(424242, [
'templates/sirsoft-basic/assets/css/components.css',
'bundles/modules.js',
]);
AssetUrl::resetStaticExtBaseMemo();
// 존재하는 파일 → 정적 URL (버전 디렉토리 경로, `?v` 불요)
$this->assertSame(
'/build/ext/424242/templates/sirsoft-basic/assets/css/components.css',
AssetUrl::templateAsset('sirsoft-basic', 'css/components.css', 424242)
);
$this->assertSame(
'/build/ext/424242/bundles/modules.js',
AssetUrl::extensionBundle('modules', 'js', 424242)
);
// 부재 파일 → 종전 API URL 그대로 (바이트 동일)
$this->assertSame(
'/api/templates/assets/sirsoft-basic/js/components.iife.js?v=424242',
AssetUrl::templateAsset('sirsoft-basic', 'js/components.iife.js', 424242)
);
$this->assertSame(
'/api/plugins/bundle.css?v=424242',
AssetUrl::extensionBundle('plugins', 'css', 424242)
);
} finally {
$this->cleanupStaticFixture();
}
}
/**
* 현재 게시 버전과 다른 `$version` 을 요구하는 호출은 정적 분기를 타지 않는다.
*
* 정적 경로는 항상 현재 게시본을 가리키므로, 다른 버전을 명시한 호출에 현재본을
* 돌려주면 "요청 버전이 URL 에 반영된다" 는 시그니처 계약이 조용히 깨진다.
* (현 호출부는 전부 현재 버전을 넘기므로 실동작 불변 — 미래 호출부 방어)
*/
public function test_요청_버전이_현재_게시_버전과_다르면_정적_분기를_건너뛴다(): void
{
try {
Cache::put('g7:core:ext.cache_version', 424242);
app()['env'] = 'production';
$this->publishFixture(424242, [
'templates/sirsoft-basic/assets/css/components.css',
]);
AssetUrl::resetStaticExtBaseMemo();
// 현재 버전 요청 → 정적
$this->assertSame(
'/build/ext/424242/templates/sirsoft-basic/assets/css/components.css',
AssetUrl::templateAsset('sirsoft-basic', 'css/components.css', 424242)
);
// 다른 버전 요청 → 종전 API URL (요청 버전 유지)
$this->assertSame(
'/api/templates/assets/sirsoft-basic/css/components.css?v=111111',
AssetUrl::templateAsset('sirsoft-basic', 'css/components.css', 111111)
);
} finally {
$this->cleanupStaticFixture();
}
}
/**
* 정적 게이트의 버전 조회는 PHP deprecation 을 발생시키지 않아야 한다.
*
* 트레이트 정적 메서드 직접 호출(`ClearsTemplateCaches::getExtensionCacheVersion()`)은
* PHP 8.1+ E_DEPRECATED 다 — blade 게이트는 매 프로덕션 요청마다 실행되므로
* 트레이트를 사용하는 클래스 경유로 호출해야 한다.
*
* @effects static_gate_requires_manifest
*/
public function test_정적_게이트는_deprecation_없이_동작한다(): void
{
try {
Cache::put('g7:core:ext.cache_version', 424242);
app()['env'] = 'production';
$this->publishFixture(424242);
AssetUrl::resetStaticExtBaseMemo();
$deprecations = [];
set_error_handler(static function (int $errno, string $errstr) use (&$deprecations): bool {
$deprecations[] = "[{$errno}] {$errstr}";
return true;
}, E_DEPRECATED | E_USER_DEPRECATED);
try {
AssetUrl::staticExtBase();
} finally {
restore_error_handler();
}
$this->assertSame([], $deprecations);
} finally {
$this->cleanupStaticFixture();
}
}
/**
* base null(게이트 미통과) 이면 기존 URL 과 바이트 동일해야 한다 (호출부 무변경 계약).
*
* @scenario publish_state=unpublished, environment=dev, trigger=kill_switch, process_user=web
*/
public function test_게이트_미통과시_종전_ur_l_바이트_동일(): void
{
AssetUrl::resetStaticExtBaseMemo();
AssetUrl::forceMode(AssetUrl::MODE_EXTENSION);
$this->assertSame(
'/api/templates/assets/sirsoft-basic/css/components.css?v=7',
AssetUrl::templateAsset('sirsoft-basic', 'css/components.css', 7)
);
$this->assertSame('/api/modules/bundle.js?v=7', AssetUrl::extensionBundle('modules', 'js', 7));
}
} }
+101
View File
@@ -0,0 +1,101 @@
feature: ext-static-cache
description: |
부트스트랩 리소스 정적 게시(bake) — 공개 #122.
초기 부트스트랩 리소스(다국어 병합·컴포넌트 정의·라우트·확장 번들·템플릿 dist
에셋)를 캐시 버전 디렉토리(`public/build/ext/{v}/`)에 실파일로 게시해 웹서버가
rewrite 전에 직접 서빙한다. 병합 입력이 바뀌는 수명주기 이벤트마다
`incrementExtensionCacheVersion()` 단일 지점의 terminating 예약으로 재게시되고,
누락 시 blade 렌더의 자가 치유가 보충한다. 미게시/부분게시/GC 직후에는 프론트
fetch 계층(fetchStaticFirst)과 태그 계층(asset-url-recovery 파샬)이 종전 API 로
폴백한다.
핵심 동작:
- 게시 원자성: {v}.tmp 쓰기 → rename → manifest 마지막 기록 (manifest 존재 = 완료)
- 서빙 게이트 3조건: 프로덕션 + core.static_cache.enabled + 게시 완료
- 태그 계층은 개별 파일 file_exists 까지 확인 후 정적 URL 방출
- blade 주입 cache_version 시드로 stale localStorage 이중 로드 제거 (작업 A)
- components.json `?v` 부착 + 버전 키드 manifestCache (작업 B)
- 폴백 API 품질: ETag/304 + 환경 분기 (작업 C·D)
axes:
publish_state: [published, unpublished, partial]
environment: [production, dev]
trigger: [lifecycle, self_heal, manual_command, kill_switch]
# 실행 주체(uid) 축 — sudo CLI(root)와 웹 계정이 갈리는 파일 산출물 기능의 필수 축.
# 7.0.10 업그레이드 실사례: root terminating 게시가 캐시 락 샤드를 root 소유로 남겨
# 웹 캐시 쓰기가 전면 500. 이 축이 없어 24축 매트릭스가 그 조합을 못 잡았다.
process_user: [web, root_cli]
exclusions:
- { environment: dev, publish_state: published, reason: "dev 는 staticBase 미주입 — 게시 상태와 무관하게 전 리소스 API 직행" }
- { environment: dev, publish_state: partial, reason: "동일 — dev 는 정적 경로 자체가 없다" }
- { trigger: kill_switch, publish_state: published, reason: "kill-switch off 는 게이트에서 base 자체를 차단 — 게시 상태 무관" }
- { trigger: kill_switch, publish_state: partial, reason: "동일" }
- { process_user: root_cli, trigger: self_heal, reason: "자가 치유는 웹 렌더 경로 전용 — root 웹 프로세스는 존재하지 않는 구성" }
- { process_user: root_cli, environment: dev, reason: "dev 는 게시 자체가 무의미 (terminating 게이트가 프로덕션 한정)" }
- { process_user: root_cli, trigger: kill_switch, reason: "kill-switch 는 uid 판정 이전의 선행 게이트 — uid 무관" }
- { process_user: root_cli, trigger: manual_command, reason: "명시적 ext-static:publish 는 root 게이트 밖 (운영자 책임 — 규정 §6). 게시 로직 자체는 web 축 케이스가 검증" }
- { process_user: root_cli, publish_state: published, reason: "root 게이트는 게시 상태 판정 이전에 위치 — 상태와 직교, 대표 조합(unpublished×lifecycle)으로 고정" }
- { process_user: root_cli, publish_state: partial, reason: "동일 — 상태 직교" }
effects:
- published_lang_matches_api_payload
- published_routes_has_success_envelope
- published_tree_excludes_inactive_extensions
- published_tree_excludes_sourcemaps
- identifier_outside_pattern_rejected
- publish_is_idempotent_until_forced
- write_failure_cleans_tmp_and_falls_back
- cleanup_keeps_current_and_previous_versions
- kill_switch_disables_publish_and_gate
- terminating_publish_gated_to_production
- terminating_publish_uses_final_version_after_burst
- static_gate_requires_manifest
- tag_layer_checks_individual_file_existence
- static_first_fetch_falls_back_to_api_on_miss
- fallback_is_observable_via_console_warn
- static_asset_tag_recovers_to_api_url
- no_duplicate_boot_requests
- versioned_boot_urls
- handshake_reload_preserved_on_version_mismatch
- manifest_cache_keys_are_version_scoped
- fallback_api_serves_etag_304
- fallback_api_no_cache_in_dev
- degraded_snapshot_gets_no_public_cache
- seo_html_never_references_gc_target_static_path
- static_miss_skips_spa_catch_all
- terminating_schedule_rearms_after_execution
- published_htaccess_declares_compression
- bundle_script_static_miss_falls_back_to_api
- root_cli_defers_publish_to_web_self_heal
test_files:
- tests/Feature/Services/ExtensionStaticCacheServiceTest.php
- tests/Feature/Api/Public/PublicComponentsCachingTest.php
- tests/Feature/Template/PublicTemplateControllerTest.php
- tests/Feature/Template/TemplateAssetServingTest.php
- tests/Feature/Template/TemplateLanguageServingTest.php
- tests/Feature/Api/Public/LayoutServingTest.php
- tests/Feature/Http/BuildPathCatchAllExclusionTest.php
- tests/Unit/Support/AssetUrlTest.php
- tests/Unit/Seo/SeoRendererStaticAssetBypassTest.php
- resources/js/core/__tests__/TemplateApp.cacheVersionSeed.test.ts
- resources/js/core/support/__tests__/fetchStaticFirst.test.ts
- resources/js/core/support/__tests__/assetUrlRecovery.test.ts
- resources/js/core/template-engine/__tests__/ComponentRegistry.manifestVersion.test.ts
- resources/js/core/modules/__tests__/ModuleAssetLoader.test.ts
- tests/Playwright/specs/smoke/bootstrap-request-dedup.spec.ts
- tests/Playwright/specs/smoke/static-cache-fallback.spec.ts
notes: |
리소스 5종(lang/components/routes/번들/dist)은 축이 아니라 effects 로 커버한다 —
게시·게이트·폴백은 리소스 무관 공통 메커니즘이고, 리소스별 형상 차이는
published_* effects (payload/봉투/소스맵 제외) 가 각각 단언한다.
environment=dev 축은 PHPUnit(환경 분기 케이스)과 AssetUrl 게이트 단위가 담당한다 —
E2E 대상 사이트(g7_2.dev)는 production 이라 dev 축을 브라우저로 실측할 수 없다.
trigger=lifecycle 의 실기기 검증(확장 비활성/활성 → 재게시 → 새 staticBase)은
Chrome MCP 매트릭스 T10a~T10d 가 수행한다 (원상 복구 의무 포함).
publish_state=partial 은 태그 계층(개별 file_exists 게이트)과 fetch 계층
(fetchStaticFirst 404 폴백) 양쪽에서 단위/브라우저로 커버된다.