feat(core,engine): 부트스트랩 리소스 정적 게시(bake) 및 폴백 체계 도입

공개 제보 https://github.com/gnuboard/g7/issues/122 대응 — 초기 부트스트랩
리소스(다국어 병합·컴포넌트 정의·라우트·확장 번들·템플릿 dist)를 캐시 버전
디렉토리(public/build/ext/{v}/)에 실파일로 게시해 웹서버가 rewrite 전에 직접
서빙한다. 부트 임계 경로의 PHP 왕복을 제거하고(실측 TTFB 131~144ms → 1~7ms),
미게시·부분게시·GC 직후에는 fetch·태그·번들 3계층이 종전 API 로 즉시 폴백한다.

- 게시: 원자적 tmp→rename→manifest(존재=완료), 캐시 락 단일 실행, 인라인 GC
 (현재+직전 1개), incrementExtensionCacheVersion 단일 지점 terminating 트리거
 + blade 자가 치유 + 설치기 태스크(best_effort) + 일일 cleanup 스케줄,
 sudo 업데이트 대비 소유권 정상화(normalizeOwnership)·prune/백업 제외
- 프론트(engine-v1.61.0): blade 주입 cache_version 1급 시드(이중 부트 로드 제거),
 fetchStaticFirst 즉시 폴백, ComponentRegistry 버전 키드 매니페스트,
 ModuleAssetLoader 번들 정적→레거시 폴백, asset-url-recovery staticToLegacy 역변환
- 폴백 API 품질: lang/components/routes ETag+304 + 환경 분기 Cache-Control,
 열화 라우트 스냅샷 공개 캐시 금지(서버측 캐시 회피와 대칭), 게시 .htaccess
 mod_deflate + nginx gzip 스니펫(압축 전송량 회귀 방지)
- SEO 정합: 봇 HTML 은 GC 대상 정적 URL 미사용(allowStatic:false), props $switch
 봇측 해석 구현(engine-v1.56.0 패리티), 패리티 룰 expression-dialect 그룹 신설,
 상주 allow 헤더 제거로 잠금 복원, _comment* 접두 주석 키 분류
- 검증: 전 수정 red→green 4단계, Playwright 라이브 21건, Chrome MCP 24축+M1~M3,
 봇 curl 3축, 캐시 저장소(file/redis/database) 축 판정, 히스토리·공개이슈·커밋
 이력 전수 재조사 반영
- 코어 7.0.10, engine-v1.61.0. kill-switch: G7_STATIC_CACHE=false
This commit is contained in:
HeuJung
2026-08-25 17:02:53 +09:00
parent 56836a2b66
commit 5ba7a83597
73 changed files with 4219 additions and 98 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.9
APP_VERSION=7.0.10
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
+1 -1
View File
@@ -3,7 +3,7 @@ APP_ENV=testing
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.9
APP_VERSION=7.0.10
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
+6
View File
@@ -145,3 +145,9 @@ test-results/
# 배포용 빌드는 `--production` 이 G7_BUILD_SOURCEMAP=0 을 주입해 생성 자체를 막는다.
# 경로 한정 시 새 확장이 추가될 때 조용히 누락되므로 전역 규칙으로 둔다.
*.map
# ===== 부트스트랩 리소스 정적 게시 (bake, #122) =====
# 병합 산출물의 버전 디렉토리 사본 — 각 서버가 수명주기 이벤트마다 재생성하는
# 로컬 파생물이라 저장소·release 페이로드에 유입되면 안 된다.
# `public/build/core/` 는 계속 추적한다 (배포 산출물) — 혼동 금지.
/public/build/ext/
+2 -1
View File
@@ -6,7 +6,7 @@
<!-- AUTO-GENERATED-START: docs-quick-reference -->
### 백엔드 [backend/](docs/backend/) (34개)
### 백엔드 [backend/](docs/backend/) (35개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
@@ -41,6 +41,7 @@
| [service-provider.md](docs/backend/service-provider.md) | 서비스 프로바이더 안전성 | DB 접근 전 .env 파일 존재 확인 필수 |
| [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 등)은 카탈로그 빌드 시점에 보강 |
| [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... |
| [user-overrides.md](docs/backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) | 모델에 `use HasUserOverrides;` + `protected array $trackable... |
| [validation.md](docs/backend/validation.md) | 검증 (Validation) | 필수: FormRequest에서 검증 (Service에 검증 로직 배치 금지) |
+15
View File
@@ -4,6 +4,21 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[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` 조건 분기 값이 검색엔진(봇) 화면에서는 해석되지 않아 해당 속성이 표시되지 않던 문제를 수정했습니다. 이제 일반 화면과 봇 화면이 동일하게 분기 값을 렌더링합니다.
## [7.0.9] - 2026-08-24
### Added
+1 -1
View File
@@ -289,7 +289,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la
# (필요 시) mv g7-7.0.9 g7
# (필요 시) mv g7-7.0.10 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
+1 -1
View File
@@ -10,7 +10,7 @@
</p>
<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/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>
+1 -1
View File
@@ -10,7 +10,7 @@
</p>
<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/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>
@@ -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;
}
}
@@ -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 {}
@@ -4,6 +4,7 @@ namespace App\Extension\Traits;
use App\Contracts\Extension\CacheInterface;
use App\Extension\Cache\CoreCacheDriver;
use App\Services\ExtensionStaticCacheService;
use Illuminate\Support\Facades\Log;
/**
@@ -48,6 +49,12 @@ trait ClearsTemplateCaches
'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 int $maxAge 캐시 유지 시간 (초, 기본: 1시간)
* @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, [
'Cache-Control' => "public, max-age={$maxAge}",
'Cache-Control' => $cacheControl,
'ETag' => $etag,
'Vary' => 'Accept-Encoding',
], ResponseHelper::JSON_ENCODE_OPTIONS);
}
@@ -283,9 +303,18 @@ abstract class BaseApiController extends Controller
): JsonResponse|Response {
$etag = $this->generateETag($data);
// 환경별 캐싱 정책 — 프로덕션 외 환경은 no-cache 로 파일/데이터 수정 즉시 반영
// (`fileResponse`/`cachedJsonResponse` 와 동일 사상, #122 작업 D)
$cacheControl = app()->environment('production')
? "public, max-age={$maxAge}"
: 'no-cache';
// 304 Not Modified 처리
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([
@@ -294,7 +323,7 @@ abstract class BaseApiController extends Controller
'data' => $data,
], 200, [], ResponseHelper::JSON_ENCODE_OPTIONS)
->header('ETag', $etag)
->header('Cache-Control', "public, max-age={$maxAge}")
->header('Cache-Control', $cacheControl)
->header('Vary', 'Accept-Encoding, Accept-Language');
}
}
@@ -140,9 +140,9 @@ class PublicModuleController extends PublicBaseController
* 폴백한다(무손실 보존 디그레이드).
*
* @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]);
@@ -139,9 +139,9 @@ class PublicPluginController extends PublicBaseController
* 폴백한다(무손실 보존 디그레이드).
*
* @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]);
@@ -35,9 +35,9 @@ class PublicTemplateController extends PublicBaseController
* 템플릿 라우트 정보 조회 (활성화된 모듈의 routes 포함)
*
* @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 사용량 기록
$this->logApiUsage('templates.routes', ['identifier' => $identifier]);
@@ -101,6 +101,15 @@ class PublicTemplateController extends PublicBaseController
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(
__('templates.messages.routes_retrieved'),
$routesData['data']
@@ -145,9 +154,9 @@ class PublicTemplateController extends PublicBaseController
* 컴포넌트 정의 파일 서빙
*
* @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 사용량 기록
$this->logApiUsage('templates.components', ['identifier' => $identifier]);
@@ -244,9 +253,9 @@ class PublicTemplateController extends PublicBaseController
*
* @param string $identifier 템플릿 식별자
* @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 사용량 기록
$this->logApiUsage('templates.language', [
+76
View File
@@ -1457,6 +1457,9 @@ class ComponentHtmlMapper
// 표현식 해석: 문자열/숫자는 evaluate, 배열/객체는 evaluateRaw
$resolved = $evaluator->evaluateRaw($value, $context);
$data[$key] = $resolved;
} elseif ($this->isSwitchDefinition($value)) {
// $switch 선언적 분기 (React resolveObject 와 동일 위치에서 해석)
$data[$key] = $this->resolveSwitchValue($value, $context, $evaluator);
} elseif (is_array($value)) {
// 중첩 객체 (예: socialLinks): 재귀적으로 해석
$data[$key] = $this->resolveAllPropsRecursive($value, $context, $evaluator);
@@ -1482,6 +1485,8 @@ class ComponentHtmlMapper
foreach ($values as $k => $v) {
if (is_string($v) && str_contains($v, '{{')) {
$result[$k] = $evaluator->evaluateRaw($v, $context);
} elseif ($this->isSwitchDefinition($v)) {
$result[$k] = $this->resolveSwitchValue($v, $context, $evaluator);
} elseif (is_array($v)) {
$result[$k] = $this->resolveAllPropsRecursive($v, $context, $evaluator);
} else {
@@ -1492,6 +1497,71 @@ class ComponentHtmlMapper
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} 패턴에서 아이템 값을 해석합니다.
*
@@ -1688,6 +1758,12 @@ class ComponentHtmlMapper
continue;
}
// $switch 선언적 분기 객체 (engine-v1.56.0 React 패리티) — 해석하지 않으면
// 배열이라는 이유로 속성이 조용히 사라진다 (예외·경고 없음)
if ($this->isSwitchDefinition($value)) {
$value = $this->resolveSwitchValue($value, $context, $evaluator);
}
if (is_string($value)) {
$evaluated = $evaluator->evaluate($value, $context);
if ($evaluated !== '') {
+11 -1
View File
@@ -730,7 +730,12 @@ class SeoRenderer implements SeoRendererInterface
foreach ($cssPaths as $cssPath) {
// dist/ 접두사 제거 (서빙 경로에서는 dist가 자동 추가됨)
$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;
@@ -954,6 +959,11 @@ class SeoRenderer implements SeoRendererInterface
* 프론트엔드 TemplateApp이 레이아웃 레벨 initLocal/initGlobal을 상태에 적용하는 것과
* 동일하게, 각 값의 {{}} 표현식을 해석해 반환합니다.
*
* **데이터소스 레벨 `initLocal` 옵션은 의도적으로 처리하지 않는다** (2026-08-25 확정) —
* 그 옵션을 쓰는 화면(장바구니·주문서·프로필 수정·게시판 작성 폼 등)은 인증·인터랙션
* 화면이라 봇 렌더 가치가 없다. 봇 노출이 필요한 상태 시드는 레이아웃 최상위
* `initLocal`/`state` 를 사용한다 (docs/backend/seo-system.md 지원 노드 키 표 참조).
*
* @param mixed $block 초기 상태 블록 (키 → 값)
* @param array $context 현재 컨텍스트 (route, query 등 포함)
* @return array 평가된 초기 상태
@@ -0,0 +1,603 @@
<?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;
/** 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;
}
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;
}
/**
* 게시 루트 디렉토리 절대 경로를 반환합니다.
*
* @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;
use App\Services\ExtensionStaticCacheService;
use App\Support\Routing\DualExtensionRoute;
/**
@@ -59,6 +60,23 @@ class AssetUrl
*/
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 모드를 반환합니다.
*
@@ -103,6 +121,105 @@ class AssetUrl
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 을 생성합니다.
*
@@ -112,11 +229,28 @@ class AssetUrl
* @param string $identifier 템플릿 식별자
* @param string $path `dist/` 이하 파일 경로 (예: `js/components.iife.js`)
* @param int|string|null $version 캐시 무효화 버전 (null 이면 미부착)
* @param bool $allowStatic 정적 게시본(bake) 경로 허용 여부. 생성한 URL 이 정적
* 게시 GC(현재+직전 1개 보존)보다 오래 사는 저장소에 박제되는
* 호출부(SEO 페이지 캐시 등)는 false 로 종전 API 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
{
// 정적 게시본(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()
? "/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', ''),
'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')))),
'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 의 "신규 최상위 항목 자동 발견" 폴백이 절대 덮어쓰면 안 되는 경로 목록.
// 런타임 데이터(`storage`), 로컬 환경(`.env*`), 별도 파이프라인 산출물(`vendor`),
// 개발 도구 메타(`.git`, `.claude`, `.codex`, `.agents`, `.serena` 등) 를 보호한다.
@@ -309,10 +312,15 @@ return [
// 백업 내부 모듈 storage 에 `.preserve-ownership` 마커가 있으면 그 서브트리만
// 자동 skip 되어 부작용 없음.
//
// `public/build/ext`: 부트스트랩 리소스 정적 게시본(#122) — sudo update 종료 시
// terminating 게시가 root 소유 버전 디렉토리를 만들면 이후 php-fpm 의 재게시·GC 가
// Permission denied 로 실패해 정적 fast path 가 영구 꺼진다(사이트는 API 폴백으로 정상).
// 임시 산출물(사용자 데이터 아님)이므로 extension_backups 와 같은 근거로 chown 대상.
//
// 환경변수 `G7_UPDATE_RESTORE_OWNERSHIP` 로 공유 호스팅 등 축소 필요 시 재정의 가능.
'restore_ownership' => array_filter(array_map('trim', explode(',', env(
'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+: 그룹 쓰기 권한 비대칭 정상화 대상.
// sudo root 로 실행된 업데이트가 umask 022 로 신규 생성한 하위 디렉토리/파일이
@@ -341,9 +349,11 @@ return [
//
// 자식 디렉토리(예: 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(
'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 자식 프로세스 실패 시 동작 모드.
+15
View File
@@ -105,6 +105,21 @@ return [
'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' => []],
'layout-previews:cleanup' => ['options' => []],
'ext-bundles:cleanup' => ['options' => []],
'ext-static:cleanup' => ['options' => []],
'seo:prune-stats' => ['options' => ['days']],
'schedules:prune-history' => ['options' => ['days']],
'identity:expire-challenges' => ['options' => []],
+3 -2
View File
@@ -9,7 +9,7 @@
| 카테고리 | 문서 수 | 링크 상태 |
|----------|---------|----------|
| [백엔드](backend/) | 35개 | 정상 |
| [백엔드](backend/) | 36개 | 정상 |
| [프론트엔드](frontend/) | 51개 | 정상 |
| [확장 시스템](extension/) | 31개 | 정상 |
| 공통 | 20개 | 정상 |
@@ -125,7 +125,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답
<!-- AUTO-GENERATED-START: docs-readme-full-list -->
## 카테고리별 전체 문서 목록
### 백엔드 (35개)
### 백엔드 (36개)
| 문서 | 제목 |
|------|------|
@@ -161,6 +161,7 @@ G7 이 제공하는 REST API 의 엔드포인트별 요청 파라미터·응답
| [service-provider.md](backend/service-provider.md) | 서비스 프로바이더 안전성 |
| [service-repository.md](backend/service-repository.md) | Service-Repository 패턴 |
| [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) |
| [user-overrides.md](backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) |
| [validation.md](backend/validation.md) | 검증 (Validation) |
+1
View File
@@ -61,6 +61,7 @@
| [service-provider.md](service-provider.md) | 서비스 프로바이더 안전성 | DB 접근 전 .env 파일 존재 확인 필수 |
| [service-repository.md](service-repository.md) | Service-Repository 패턴 | RepositoryInterface 주입 필수 (구체 클래스 직접 주입 금지) |
| [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... |
| [user-overrides.md](user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) | 모델에 `use HasUserOverrides;` + `protected array ... |
| [validation.md](validation.md) | 검증 (Validation) | 필수: FormRequest에서 검증 (Service에 검증 로직 배치 금지) |
+2 -2
View File
@@ -2302,7 +2302,7 @@ HTTP/1.1 200
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
**설명** 모듈 컴포넌트 매니페스트(`GET /api/modules/{identifier}/components.json`)의 확장자 없는 이중 모드 변형입니다. 응답·캐시·폴백 동작이 확장자 형태와 동일하며, `.json` 주소를 가로채는 정적 파일 최적화 서버 설정에서 프론트가 이 형태로 자동 전환합니다 (자산 URL 이중 모드).
### GET /api/modules/{identifier}/components.json
@@ -2373,7 +2373,7 @@ HTTP/1.1 200
<!-- @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
+1 -1
View File
@@ -2660,7 +2660,7 @@ Cache-Control: public, max-age=3600
<!-- @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
+6 -6
View File
@@ -97259,7 +97259,7 @@ HTTP/1.1 200
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
**설명** 템플릿 컴포넌트 정의(`GET /api/templates/{identifier}/components.json`)의 확장자 없는 이중 모드 변형입니다. 응답·캐시·폴백 동작이 확장자 형태와 동일하며, `.json` 주소를 가로채는 정적 파일 최적화 서버 설정에서 프론트가 이 형태로 자동 전환합니다 (자산 URL 이중 모드).
### GET /api/templates/{identifier}/components.json
@@ -97327,7 +97327,7 @@ HTTP/1.1 200
<!-- @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
@@ -189484,7 +189484,7 @@ HTTP/1.1 200
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
**설명** 템플릿 다국어(`GET /api/templates/{identifier}/lang/{locale}.json`)의 확장자 없는 이중 모드 변형입니다. 응답·캐시·폴백 동작이 확장자 형태와 동일하며, `.json` 주소를 가로채는 정적 파일 최적화 서버 설정에서 프론트가 이 형태로 자동 전환합니다 (자산 URL 이중 모드).
### GET /api/templates/{identifier}/lang/{locale}.json
@@ -189539,7 +189539,7 @@ _이 엔드포인트는 `success`/`message`/`data` 봉투를 사용하지 않습
<!-- @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
@@ -189705,7 +189705,7 @@ HTTP/1.1 200
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
**설명** 템플릿 라우트(`GET /api/templates/{identifier}/routes.json`)의 확장자 없는 이중 모드 변형입니다. 응답·캐시·폴백 동작이 확장자 형태와 동일하며, `.json` 주소를 가로채는 정적 파일 최적화 서버 설정에서 프론트가 이 형태로 자동 전환합니다 (자산 URL 이중 모드).
### GET /api/templates/{identifier}/routes.json
@@ -189773,6 +189773,6 @@ HTTP/1.1 200
<!-- @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` (대소문자·공백 무시) |
| `condition` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::shouldRender` | `if` 의 별칭 |
| `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` 변환 |
| `classMap` | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::resolveClassMap` | |
| `responsive` | 전체 브레이크포인트 | 데스크톱 폭만 | `app/Seo/ComponentHtmlMapper.php::applyResponsiveOverrides` / `matchingBreakpointKey` | 봇=데스크톱 고정. `props`/`if`/`text`/`children`/`iteration` 오버라이드 반영. 매칭 키가 여럿이면 **하나만** 적용 — 커스텀 범위 > 프리셋, 좁은 범위 > 넓은 범위 (양쪽 동일) |
@@ -1131,11 +1131,13 @@ SEO 렌더링은 `renderComponent()` 진입 시점에 이 값을 해석해 데
| `isolatedState` / `$parent` / `_isolated` | ✅ | ❌ (무해) | 미처리 | 격리·모달 부모 컨텍스트 |
| `blur_until_loaded` / 노드 최상위 `style` | ✅ | ❌ (무해) | 미처리 | 표현 전용 |
| 노드 최상위에 잘못 놓인 컴포넌트 prop (`size` 등) | ❌ | ❌ | 미처리 | 양쪽 모두 `props` 객체만 읽으므로 무시됨 — 값을 적용하려면 `props` 안으로 옮겨야 함 |
| `comment` / `_comment` 접두 계열 (`_comment_id` 등) | ❌ | ❌ | 미처리 | 개발자 주석 메타 — 양쪽 렌더러 모두 무시. `_comment` 는 접두사 계열로 판정한다 (`SeoNodeKeyParityTest::isCommentKey`) |
| `props.isHtml` (콘텐츠 노드) | ✅ | ✅ | `app/Seo/ComponentHtmlMapper.php::renderRawMode` | 거짓이면 이스케이프, 참(기본)이면 정화 후 HTML — "봇 화면의 HTML 정화" 참조 |
| `props.value` (폼 제어) | 속성 | 속성 | `app/Seo/ComponentHtmlMapper.php::resolveTextContent` | `select`/`option`/`input`/`textarea` 등에서는 글자로 승계하지 않음 — 선택 목록은 `options` 로 항목 라벨을 그림 |
| `props.purifyConfig` | ✅ | ❌ (의도) | 미처리 | 봇 화면은 기본 정화 규칙만 적용 |
| `modals` | ✅ | ❌ (무해) | `SeoRenderer` 가 `components` 만 렌더 | 봇 화면은 모달을 렌더하지 않음 |
| 레이아웃 최상위 `state` / `initLocal` / `initGlobal` | ✅ | ✅ | `app/Seo/SeoRenderer.php::resolveInitStateBlock` / `resolveInitActionState` | `init_actions` 의 상태 설정(로컬/전역)도 반영 |
| **데이터소스 레벨** `initLocal` 옵션 | ✅ | ❌ (의도) | 미처리 | 봇 화면 미지원 확정 (2026-08-25) — 이 옵션을 쓰는 화면(장바구니·주문서·프로필 수정·게시판 작성 폼 등)은 인증·인터랙션 화면이라 봇 렌더 가치가 없다. 봇 노출이 필요한 상태 시드는 레이아웃 최상위 `initLocal`/`state` 를 사용한다 |
이 표는 `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 로 실행되면 종료 시점의 terminating 게시가 root 소유 산출물을 만들 수 있다. 이는 코어 업데이트의 소유권 복원(`app.update.restore_ownership`) **이후**에 일어나므로, 게시 서비스가 직접 방어한다 — root 로 실행된 게시는 완료 직후 부모 디렉토리(`public/build`) 소유권을 산출물에 상속시킨다. `public/build/ext` 는 소유권 복원·그룹 쓰기 정상화 목록에도 포함되어 있다.
- **코어 업데이트의 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) 이 소유한다. 본 게시는 그 산출물의 **사본**만 만든다.
+2
View File
@@ -449,6 +449,8 @@ GET /api/modules/assets/{identifier}/{path}
활성 모듈/플러그인이 늘어날수록 개별 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
# 인스톨러 완료 후 운영자가 1회 실행
sudo chown -R $USER:www-data storage bootstrap/cache vendor modules plugins templates
sudo chmod -R 775 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 public/build
```
추가로 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 (단일 소유자)**:
```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 동조는 발동하지 않는다 (운영자 의도 존중). 추가 설정 불필요.
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')) {
function createSettingsJsonSSE(): array
{
@@ -1859,6 +1880,9 @@ if (! function_exists('runInstallationTasks')) {
// complete_flag(installer_completed=true) 이후에 config 캐시를 최초 생성해야
// 설치가 config:cache 켜진 상태로 시작한다. best_effort — 실패해도 설치는 완료.
$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) {
if (checkAbortStatusSSE()) {
+3
View File
@@ -280,6 +280,7 @@ return [
'task_create_settings_json' => 'Creating Settings Files',
'task_complete_flag' => 'Finalizing Installation',
'task_config_cache' => 'Building Configuration Cache',
'task_static_publish' => 'Publishing Static Bootstrap Resources',
'task_unknown' => 'Unknown Task',
// Task Group Names
@@ -382,10 +383,12 @@ return [
// Error Messages - Worker (Cache)
'error_cache_clear_failed' => 'Cache clearing 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_cache_clear_success' => 'Cache clearing completed',
'log_config_cache_success' => 'Configuration cache built',
'log_static_publish_success' => 'Static bootstrap resources published',
// Error Messages - Worker (Settings JSON)
'error_settings_json_failed' => 'Settings file creation failed',
+3
View File
@@ -280,6 +280,7 @@ return [
'task_create_settings_json' => '설정 파일 생성',
'task_complete_flag' => '설치 완료 처리',
'task_config_cache' => '설정 캐시 생성',
'task_static_publish' => '부트스트랩 리소스 정적 게시',
'task_unknown' => '알 수 없는 작업',
// 작업 그룹명
@@ -382,10 +383,12 @@ return [
// 에러 메시지 - Worker (Cache)
'error_cache_clear_failed' => '캐시 클리어에 실패했습니다',
'error_config_cache_failed' => '설정 캐시 생성에 실패했습니다',
'error_static_publish_failed' => '부트스트랩 리소스 정적 게시에 실패했습니다 (사이트는 API 경로로 정상 동작합니다)',
// 로그 메시지 - Worker (Cache)
'log_cache_clear_success' => '캐시 클리어 완료',
'log_config_cache_success' => '설정 캐시 생성 완료',
'log_static_publish_success' => '부트스트랩 리소스 정적 게시 완료',
// 에러 메시지 - Worker (Settings JSON)
'error_settings_json_failed' => '설정 파일 생성에 실패했습니다',
+25 -9
View File
@@ -31,8 +31,9 @@ import { createLogger, Logger } from './utils/Logger';
import { webSocketManager } from './websocket/WebSocketManager';
import { getModuleAssetLoader, parseModuleAssetsFromConfig, parsePluginAssetsFromConfig, parseBundleUrlsFromConfig } from './modules';
import { SystemBannerManager } from './template-engine/SystemBannerManager';
import { fetchWithRetry, installUnloadGuard, isDocumentUnloading } from './template-engine/networkResilience';
import { suffixed } from './support/assetUrl';
import { installUnloadGuard, isDocumentUnloading } from './template-engine/networkResilience';
import { suffixed, extStaticUrl } from './support/assetUrl';
import { fetchStaticFirst } from './support/fetchStaticFirst';
import { resetLocalInitTracking } from './template-engine/localInitSlot';
/**
* DevTools 추적 - G7DevToolsCore.getInstance() 직접 호출 대신 G7Core.devTools를 사용합니다.
@@ -456,8 +457,13 @@ export class TemplateApp {
const componentRegistry = ComponentRegistry.getInstance();
const authManager = AuthManager.getInstance();
// 저장된 캐시 버전 로드 (초기 API 호출에 사용)
const storedCacheVersion = this.loadCacheVersionFromStorage() || 0;
// 캐시 버전 시드 — blade 주입값(현재 렌더와 동일 버전) 우선, 부재 시 localStorage 폴백.
// 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([
// 템플릿 엔진 초기화 (다국어 파일 병렬 로드)
@@ -471,11 +477,16 @@ export class TemplateApp {
// ComponentRegistry 로딩 (components.json)
componentRegistry.loadComponents(
this.config.templateId,
this.config.templateType
this.config.templateType,
storedCacheVersion
),
// routes.json 로딩 (저장된 캐시 버전 사용)
// 네트워크 일시 실패(응답 없음)에만 재시도한다. HTTP 에러는 아래 체인이 종전대로 throw.
fetchWithRetry(
// 정적 게시본(bake) 우선 — miss 면 즉시 종전 API 로 폴백 (#122).
// 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),
{ label: 'routes.json' }
)
@@ -526,7 +537,9 @@ export class TemplateApp {
// 확장 기능 캐시 버전 저장 (모듈/플러그인 활성화 시 갱신됨)
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.saveCacheVersionToStorage(this.extensionCacheVersion);
logger.log('Extension cache version:', this.extensionCacheVersion);
@@ -535,7 +548,10 @@ export class TemplateApp {
if (previousVersion !== null && previousVersion !== this.extensionCacheVersion) {
logger.log('Cache version changed, reloading routes...');
// 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),
{ 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 { loadScriptWithRetry } from '../template-engine/networkResilience';
import { convertToCurrentMode } from '../support/assetUrl';
import { convertToCurrentMode, staticToLegacy } from '../support/assetUrl';
const logger = createLogger('ModuleAssetLoader');
@@ -204,6 +204,18 @@ export class ModuleAssetLoader {
};
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})`);
resolve();
};
@@ -240,7 +252,25 @@ export class ModuleAssetLoader {
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(() => {
logger.log(`Bundle JS loaded: ${key}`);
const script = document.getElementById(elementId);
@@ -22,6 +22,84 @@ describe('ModuleAssetLoader', () => {
.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', () => {
it('JS 에셋을 priority 오름차순으로 DOM에 append한다 (실행 순서 보장)', async () => {
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(
'/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(
'/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(layoutPreviewUrl('abc-def')).toBe('/api/layouts/preview/abc-def.json');
});
@@ -20,6 +20,7 @@ import {
getAssetUrlMode,
setAssetUrlMode,
convertToCurrentMode,
staticToLegacy,
} 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 으로 판정', () => {
/**
* 프로브 판정 로직은 두 곳에 존재한다 — 관리자 핸들러(`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');
}
/**
* 정적 게시(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 을 **현재 모드**에 맞게 변환합니다.
*
@@ -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/)
## [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
### Fixed
@@ -12,8 +12,8 @@
import React, { type ComponentType } from 'react';
import { createLogger } from '../utils/Logger';
import { fetchWithRetry } from './networkResilience';
import { suffixed } from '../support/assetUrl';
import { suffixed, extStaticUrl } from '../support/assetUrl';
import { fetchStaticFirst } from '../support/fetchStaticFirst';
const logger = createLogger('ComponentRegistry');
@@ -126,6 +126,9 @@ export class ComponentRegistry {
/** 컴포넌트 매니페스트 */
private manifest: ComponentManifest | null = null;
/** 확장 캐시 버전 (0 = 무버전 URL — 편집기 경로) */
private cacheVersion = 0;
/** 로딩 상태 */
private loadingState: LoadingState = 'idle';
@@ -182,8 +185,11 @@ export class ComponentRegistry {
*
* @param templateId 템플릿 식별자
* @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') {
throw new ComponentRegistryError(
'Components are already being loaded',
@@ -199,6 +205,7 @@ export class ComponentRegistry {
this.loadingState = 'loading';
this.templateId = templateId;
this.templateType = templateType;
this.cacheVersion = cacheVersion;
this.error = null;
try {
@@ -235,8 +242,8 @@ export class ComponentRegistry {
);
}
// 캐시 키 생성
const cacheKey = `${this.templateId}:${this.templateType}`;
// 캐시 키 생성 (버전 포함 — 확장 라이프사이클 후 stale 매니페스트 교차 오염 방지)
const cacheKey = `${this.templateId}:${this.templateType}:v${this.cacheVersion || 0}`;
// 캐시 확인
if (ComponentRegistry.manifestCache.has(cacheKey)) {
@@ -248,8 +255,20 @@ export class ComponentRegistry {
// 캐시 미스 - API에서 로드
// 네트워크 일시 실패(응답 없음)에만 재시도. HTTP 에러는 아래 !ok 분기가 종전대로 처리.
// @since engine-v1.53.0
const manifestUrl = suffixed(`/api/templates/${this.templateId}/components`, 'json');
const response = await fetchWithRetry(manifestUrl, { label: 'components.json' });
const manifestUrl = suffixed(
`/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) {
throw new ComponentRegistryError(
@@ -13,7 +13,8 @@
*/
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) 이지만
// 양쪽 모두 모듈 평가 시점이 아니라 메서드 실행 시점에만 서로를 참조하므로
// live binding 이 채워진 뒤에 사용된다.
@@ -242,7 +243,15 @@ export class TranslationEngine {
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) {
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 와일드카드를
// 처리하지 못해 캐시가 영구 stale 되는 결함이 발생.
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'.
// 정적 최적화 블록이 동적 응답을 가로채는 서버에서 확장자 없는 형태로 전환.
// 부트스트랩 자가 복구가 런타임에 뒤집으므로 최상위 키로 노출한다.
+5
View File
@@ -60,6 +60,11 @@
// 확장 캐시 버전 SSoT — 클라이언트 fetch (`?v=`) 동반 필수.
// 자세한 설명은 admin.blade.php 참조.
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'.
// 정적 최적화 블록(location ~* \.(js|css|json)$)이 동적 응답을 가로채는
// 서버에서 확장자 없는 형태로 전환한다. 부트스트랩 자가 복구가 실패 시
+8
View File
@@ -1070,6 +1070,14 @@ if (isset($_GET['ajax_action'])) {
<span>확장 번들 정리</span>
<span class="text-[10px] opacity-60">(ext-bundles:cleanup)</span>
</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">
<span>SEO 캐시 통계 정리</span>
<span class="text-[10px] opacity-60">(seo:prune-stats)</span>
@@ -20,6 +20,19 @@
L3 자동 location.reload() 금지.
L5 서버 설정은 건드리지 않는다 (미인증 클라이언트의 전역 설정 변경 금지).
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>
(function () {
@@ -89,6 +102,52 @@
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회).
*
@@ -157,7 +216,26 @@
if (!link || link.getAttribute('data-g7-recovered') === '1') return;
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;
switchToExtensionless();
@@ -169,6 +247,7 @@
window.__g7AssetUrl = {
MODE_EXTENSIONLESS: MODE_EXTENSIONLESS,
toExtensionless: toExtensionless,
staticToLegacy: staticToLegacy,
switchToExtensionless: switchToExtensionless,
restoreCachedMode: restoreCachedMode,
recoverStylesheet: recoverStylesheet
@@ -94,6 +94,9 @@
/** 모드 전환을 이미 시도했는지 (L1 — 페이지 수명당 1회) */
modeSwitched: false,
/** 정적 게시 URL → API URL 전환을 이미 시도했는지 (#122 F15 — 페이지 수명당 1회) */
staticRecovered: false,
/**
* 정적 <script> 로드 실패 시 동적 재시도를 시작한다.
*
@@ -115,6 +118,20 @@
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) — 확장자 형태가 실패했고 아직 전환 전이라면,
// 지연 재시도 대신 확장자 없는 형태로 **즉시 1회** 시도한다.
// 이 시도는 기존 예산(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('notification:cleanup')->dailyAt('04:20')->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('schedules:prune-history')->dailyAt('04:35')->onOneServer();
Schedule::command('identity:prune-logs')->dailyAt('04:40')->onOneServer();
@@ -911,4 +911,71 @@ class LayoutServingTest extends TestCase
// 메시지도 raw UTF-8 (레이아웃 제공 성공 메시지)
$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 * * *',
'notification:cleanup' => '20 4 * * *',
'ext-bundles:cleanup' => '25 4 * * *',
'ext-static:cleanup' => '28 4 * * *',
'seo:prune-stats' => '30 4 * * *',
'schedules:prune-history' => '35 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,427 @@
<?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
*/
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
*
* @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
*
* @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
*
* @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
*
* @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
*
* @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));
}
/**
* terminating 게시 예약 플래그는 콜백 실행 시점에 리셋된다.
*
* 요청마다 앱 인스턴스를 새로 만드는 장수 프로세스(Octane 류)에서는 static 플래그만
* 프로세스에 살아남는다 — 리셋이 없으면 2번째 이후 bump 는 새 앱에 콜백이 등록되지
* 않은 채 플래그만 true 라 그 워커에서 영구 미게시가 되고, `AssetUrl` 자가 치유도
* 같은 플래그를 쓰므로 복구 경로가 없다. FPM(요청=프로세스)에서는 무영향.
*
* @scenario publish_state=published, environment=production, trigger=lifecycle
*
* @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
*
* @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\Enums\ExtensionStatus;
use App\Models\Template;
use App\Services\TemplateService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
@@ -165,6 +166,151 @@ class PublicTemplateControllerTest extends TestCase
$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)
*
@@ -5,6 +5,7 @@ namespace Tests\Feature\Template;
use App\Enums\ExtensionStatus;
use App\Models\Template;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Tests\TestCase;
class TemplateAssetServingTest extends TestCase
@@ -12,7 +13,9 @@ class TemplateAssetServingTest extends TestCase
use RefreshDatabase;
private Template $activeTemplate;
private string $testTemplatePath;
private string $testIdentifier;
protected function setUp(): void
@@ -455,7 +458,7 @@ class TemplateAssetServingTest extends TestCase
// Assert
$response->assertStatus(200);
// 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');
}
@@ -534,7 +537,8 @@ class TemplateAssetServingTest extends TestCase
*/
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");
file_put_contents($componentsPath, json_encode([
'Button' => ['path' => 'components/Button.jsx'],
@@ -555,6 +559,56 @@ class TemplateAssetServingTest extends TestCase
$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
*
* @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 반환
*/
@@ -155,6 +155,9 @@ class TemplateLanguageServingTest extends TestCase
*/
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->assertStatus(200);
@@ -166,6 +169,44 @@ class TemplateLanguageServingTest extends TestCase
$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
*/
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 반환
*/
@@ -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
* @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, ' | '));
}
/**
* 정적 게시(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)', () => {
// 각 경로의 첫 요청 1건을 취소해도 재시도로 복구되어 앱이 정상 렌더돼야 한다.
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.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await page.waitForFunction(
@@ -234,8 +261,9 @@ test.describe('네트워크 복원력 — 번들이 끝내 부재할 때 (#463)'
expect(text).toMatch(FALLBACK_UI);
await expect(page.locator('#app button')).toBeVisible();
// 재시도 3시도 (초기 1 + 재시도 2)
expect(attempts).toBe(3);
// 총 3시도 (초기 1 + 재시도 2) — 게시 사이트는 정적 1 + API 2, 미게시는 API 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
* @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
* @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);
});
});
+19 -1
View File
@@ -56,6 +56,9 @@ class SeoNodeKeyParityTest extends TestCase
'id',
'key',
'comment',
// `_comment` 는 접두사 계열이다 — 저장소 관례상 `_comment_id`·`_comment_hidden` 등
// 문맥 이름을 붙인 변형이 통용된다(전수 8종). 정확일치로만 등록하면 새 변형마다
// 이 테스트가 red 가 되므로 isCommentKey() 가 접두사로 판정한다.
'_comment',
// 설치 시점에 인라인 확장되므로 SEO 렌더 시에는 남지 않는다
'partial',
@@ -110,7 +113,7 @@ class SeoNodeKeyParityTest extends TestCase
}
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);
}
}
@@ -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 파일도 실제로 스캔됩니다.
*
@@ -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);
}
}
@@ -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` 타깃을 정리할 때
* `public/storage` symlink 를 orphan 으로 삭제하지 않고 보존합니다.
+214
View File
@@ -2,7 +2,10 @@
namespace Tests\Unit\Support;
use App\Services\ExtensionStaticCacheService;
use App\Support\AssetUrl;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
/**
@@ -14,9 +17,25 @@ use Tests\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
{
AssetUrl::forceMode(null);
File::deleteDirectory($this->isolatedPublicPath);
parent::tearDown();
}
@@ -172,4 +191,199 @@ class AssetUrlTest extends TestCase
$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
*
* @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
*
* @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
*/
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));
}
}
+90
View File
@@ -0,0 +1,90 @@
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]
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: "동일" }
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
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 폴백) 양쪽에서 단위/브라우저로 커버된다.