diff --git a/AGENTS.md b/AGENTS.md index 8c4c5138..203a1205 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -320,6 +320,18 @@ Icon 은 `` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은 정규식 location 은 프리픽스 location 보다 먼저 매칭되므로, 정적 최적화 블록(`location ~* \.(js|css|json)$`)이 있는 서버에서는 확장자 붙은 동적 응답이 `try_files ... /index.php` 폴백 기회 없이 404 가 된다. 서버측 `AssetUrl` 과 프론트측 `assetUrl.ts` 는 동일 규칙을 공유하므로 한쪽만 바꾸면 그 자산만 404 가 된다. 상세: [routing.md](docs/backend/routing.md) "정적 확장자로 끝나는 동적 엔드포인트", [api/README.md](docs/backend/api/README.md) "자산 URL 이중 모드". +### 라우트 캐시 안전성 + +`route:cache` 가 걸리면 `RouteServiceProvider::boot()` 이 캐시 로드로 분기해 라우트 파일 자체가 실행되지 않는다. 클로저는 직렬화 형태로 복원되므로 문제가 아니다 (`routes/web.php` SPA catch-all 이 증거). 깨지는 것은 오토로드되지 않는 심볼 참조뿐이다. + +| 금지 | 올바른 사용 | +|------|------------| +| 라우트 파일에 전역 함수 선언 + 핸들러가 호출 | 로직을 클래스(`app/Support/…`)로 옮기고 핸들러는 위임만 | +| 파일 스코프 변수를 핸들러가 `use` 없이 참조 | 클래스 상수 또는 `use ($var)` 로 클로저에 캡처 | +| 벤더/프로바이더가 `boot()` 에서 조건부 등록하는 라우트에 의존 | 그 URI 를 G7 라우트 파일이 직접 소유 | + +전역 함수 위반은 `Call to undefined function` 500 인데 예외의 `file` 이 `laravel-serializable-closure://` 라 원인 파일이 스택에 드러나지 않는다. 프로바이더 등록분이 사라지는 이유는 별개다 — `Router::setCompiledRoutes()` 가 `booted` 콜백에서 라우트 컬렉션을 통째로 교체하므로 그보다 앞선 등록은 조건 충족 여부와 무관하게 폐기된다(프레임워크 자신의 `BroadcastManager::routes()` 는 `routesAreCached()` 가드를 갖지만 모든 패키지가 그렇지는 않다). 정적 검사가 라우트 파일의 전역 함수 선언을 차단한다. 상세: [routing.md](docs/backend/routing.md) "캐시 안전한 라우트 작성". + ### 목록 컨텍스트 왕복 (list context round-trip) 페이지네이션 목록 화면과 그에 딸린 상세·형제 상세·작성/수정 폼·확인 모달은 하나의 목록 클러스터다. 이 클러스터 안에서의 이동은 URL 목록 상태(`page`/`search`/`category`/`filters[*]`/정렬/`per_page`)를 손실 없이 보존해야 한다. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4853f747..759e052e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -176,6 +176,13 @@ - 모듈·플러그인 업데이트가 실패하거나 도중에 중단된 뒤, 확장은 정상인데 그 관리자 화면만 "페이지를 찾을 수 없습니다"로 나오고 하루가 지나야 돌아오던 문제를 수정했습니다. 업데이트가 진행되는 동안 사이트를 열어 본 사람이 있으면 그 순간의 "이 확장은 사용 중이 아님" 상태가 기억되는데, 실패로 끝나면 그 기억이 정리되지 않았습니다. 이제 실패로 끝나도 즉시 정리되며, 잘못된 상태가 애초에 기억되지 않도록 함께 보완했습니다. - 데이터가 많은 사이트를 업그레이드할 때 기존 데이터를 채워 넣는 작업이 일부 행을 건너뛰던 문제를 수정했습니다. 100건씩 나눠 처리하면서 처리한 행이 대상 목록에서 빠지는 만큼 다음 묶음의 시작 위치가 밀려, 250건이면 100건이 처리되지 않은 채 남았습니다. 레이아웃 원본 대조값 채우기와 `json:convert-unicode` 명령이 대상이며, 이제 건수와 무관하게 전건이 처리됩니다. (#84 @glitter-gim 님께서 제보해주셨습니다.) +#### 개발자 도구 + +- 디버그 모드에서 개발자 도구의 상태 내려받기가 오류를 내며 실패하던 문제를 수정했습니다. 모듈·플러그인을 설치하거나 켜고 끄면 사이트가 라우트 정보를 미리 만들어 두는데, 그 상태에서는 상태 내려받기 기능이 필요한 코드를 찾지 못했습니다. 확장을 한 번이라도 건드린 사이트에서는 항상 실패했습니다. +- 같은 조건에서 브라우저 콘솔 로그 수집이 조용히 멈추던 문제를 수정했습니다. 로그를 받는 주소가 사라진 상태였는데 실패가 화면에도 기록에도 남지 않아, 디버그 모드를 켜 두었는데도 `storage/logs/browser.log` 만 비어 있었습니다. +- 관리자 환경설정에서만 디버그 모드를 켰을 때(`.env` 는 꺼진 상태) 개발자 도구 요청이 접근 거부 대신 오류로 응답하던 문제를 수정했습니다. +- 개발자 도구 응답 문구가 사이트 언어와 무관하게 항상 한국어로만 나오던 것을 사이트 언어를 따르도록 바꿨습니다. + #### 설정 저장·입력 검증 - 모듈·플러그인 설정의 숫자 항목이 숫자가 아닌 형태로 보관되어 있어도, 조회 시 설정 기본값과 같은 숫자 형태로 처리합니다. 이전에는 저장된 형태에 따라 기한 계산 같은 후속 처리에서 오류가 발생할 수 있었습니다. 숫자가 아닌 값, 참/거짓 항목, 목록 항목은 그대로 유지됩니다. 설정 안에 다시 묶여 있는 하위 항목까지 같은 규칙이 적용됩니다. diff --git a/app/Support/DevTools/BrowserLogWriter.php b/app/Support/DevTools/BrowserLogWriter.php new file mode 100644 index 00000000..ea9a7b6b --- /dev/null +++ b/app/Support/DevTools/BrowserLogWriter.php @@ -0,0 +1,146 @@ +input('logs', []); + + // 빈 요청이 browser.log 파일을 만들지 않도록 채널 해석 자체를 미룬다. + if (! is_array($logs) || $logs === []) { + return response()->json(['status' => 'logged']); + } + + $logger = self::channel(); + + foreach ($logs as $log) { + if (! is_array($log)) { + continue; + } + + $logger->log( + self::level($log['type'] ?? 'log'), + self::buildMessage($log['data'] ?? []), + [ + 'url' => $log['url'] ?? null, + 'user_agent' => ($log['userAgent'] ?? null) ?: null, + 'timestamp' => ($log['timestamp'] ?? null) ?: now()->toIso8601String(), + ] + ); + } + + return response()->json(['status' => 'logged']); + } + + /** + * 브라우저 로그 타입을 PSR-3 로그 레벨로 변환합니다. + * + * boost 원본은 `$log['type']` 이 없으면 `match` 에서 UnhandledMatchError 로 터진다. + * 여기서는 호출부에서 기본값을 채워 넘긴다. + * + * @param mixed $type 브라우저 로그 타입 + * @return string PSR-3 로그 레벨 + */ + private static function level(mixed $type): string + { + return match ($type) { + 'warn' => 'warning', + 'log', 'table' => 'debug', + 'window_error', 'uncaught_error', 'unhandled_rejection' => 'error', + 'error', 'info', 'notice', 'critical', 'alert', 'emergency' => $type, + default => 'debug', + }; + } + + /** + * 브라우저 로그 data 배열을 사람이 읽을 수 있는 한 줄 메시지로 만듭니다. + * + * 중첩 배열(예: `{"message":"...","reason":{"name":"TypeError",...}}`)도 재귀 평탄화한다. + * + * @param mixed $data 로그 data 페이로드 + * @return string 조립된 메시지 + */ + private static function buildMessage(mixed $data): string + { + if (! is_array($data)) { + return self::stringify($data); + } + + return implode(' ', array_map( + static fn (mixed $value): string => is_array($value) + ? self::buildMessage($value) + : self::stringify($value), + $data + )); + } + + /** + * 스칼라/객체 값을 문자열로 변환합니다. + * + * @param mixed $value 변환할 값 + * @return string 문자열 표현 + */ + private static function stringify(mixed $value): string + { + return match (true) { + is_string($value) => $value, + is_numeric($value) => (string) $value, + is_bool($value) => $value ? 'true' : 'false', + is_null($value) => 'null', + default => (string) json_encode($value), + }; + } + + /** + * 브라우저 로그 채널을 반환합니다 (미정의 시 즉석 생성). + * + * @return LoggerInterface 로그 채널 + */ + private static function channel(): LoggerInterface + { + if (config('logging.channels.browser') !== null) { + return Log::channel('browser'); + } + + return Log::build([ + 'driver' => 'single', + 'path' => storage_path('logs/browser.log'), + 'level' => config('logging.level', 'debug'), + ]); + } +} diff --git a/app/Support/DevTools/DebugDumpWriter.php b/app/Support/DevTools/DebugDumpWriter.php new file mode 100644 index 00000000..9ceb34a1 --- /dev/null +++ b/app/Support/DevTools/DebugDumpWriter.php @@ -0,0 +1,284 @@ + + */ + public const SECTION_FILES = [ + 'state' => 'state-latest.json', + 'actions' => 'actions-latest.json', + 'cache' => 'cache-latest.json', + 'lifecycle' => 'lifecycle-latest.json', + 'network' => 'network-latest.json', + 'expressions' => 'expressions-latest.json', + 'forms' => 'form-latest.json', + 'performance' => 'performance-latest.json', + 'conditionals' => 'conditionals-latest.json', + 'dataSources' => 'datasources-latest.json', + 'handlers' => 'handlers-latest.json', + 'componentEvents' => 'component-events-latest.json', + 'stateRendering' => 'state-rendering-latest.json', + 'stateHierarchy' => 'state-hierarchy-latest.json', + 'contextFlow' => 'context-flow-latest.json', + 'styleValidation' => 'style-validation-latest.json', + 'authDebug' => 'auth-debug-latest.json', + 'logs' => 'logs-latest.json', + 'layout' => 'layout-latest.json', + 'changeDetection' => 'change-detection-latest.json', + 'sequenceTracking' => 'sequence-latest.json', + 'staleClosureTracking' => 'stale-closure-latest.json', + 'cacheDecisionTracking' => 'cache-decisions-latest.json', + 'dataPathTransformTracking' => 'data-path-transform-latest.json', + 'nestedContextTracking' => 'nested-context-latest.json', + 'formBindingValidationTracking' => 'form-binding-validation-latest.json', + 'computedDependencyTracking' => 'computed-dependency-latest.json', + 'modalStateScopeTracking' => 'modal-state-scope-latest.json', + 'namedActionTracking' => 'named-action-tracking-latest.json', + ]; + + /** 이력 파일(`state-{timestamp}.json`)을 남기는 섹션 */ + private const HISTORY_SECTION = 'state'; + + /** 오래된 세션 디렉토리 정리 기준 (초) */ + private const SESSION_TTL_SECONDS = 600; + + /** JSON 저장 공통 플래그 */ + private const JSON_FLAGS = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE; + + /** + * 섹션별 청크 분할 전송을 처리합니다 (대용량 데이터). + * + * @param Request $request 덤프 요청 + * @param string $debugDir 최종 저장 디렉토리 + * @param string $sessionsDir 청크 임시 디렉토리 + * @return JsonResponse 처리 결과 + */ + public static function handleSectional(Request $request, string $debugDir, string $sessionsDir): JsonResponse + { + $sessionId = $request->input('sessionId'); + $sectionName = $request->input('sectionName'); + $chunkData = $request->input('chunkData'); + $chunkIndex = (int) $request->input('chunkIndex', 0); + $totalChunks = (int) $request->input('totalChunks', 1); + $isLastChunk = $request->boolean('isLastChunk'); + $isLastSection = $request->boolean('isLastSection'); + $timestamp = $request->input('timestamp'); + $saveHistory = $request->boolean('saveHistory'); + + // 세션 디렉토리 생성 + $sessionDir = $sessionsDir.'/'.$sessionId; + if (! File::isDirectory($sessionDir)) { + File::makeDirectory($sessionDir, 0755, true); + } + + // 청크를 임시 파일에 저장 + File::put($sessionDir.'/'.$sectionName.'.chunk.'.$chunkIndex, $chunkData); + + // 해당 섹션의 마지막 청크이면 모든 청크를 병합 + if ($isLastChunk) { + $mergedData = ''; + for ($i = 0; $i < $totalChunks; $i++) { + $chunkPath = $sessionDir.'/'.$sectionName.'.chunk.'.$i; + if (File::exists($chunkPath)) { + $mergedData .= File::get($chunkPath); + File::delete($chunkPath); + } + } + + File::put($sessionDir.'/'.$sectionName.'.json', $mergedData); + } + + // 전체 덤프의 마지막 섹션이면 최종 파일 생성 후 세션 정리 + if ($isLastSection) { + self::mergeSectionsToFinalFiles($sessionDir, $debugDir, $saveHistory, $timestamp); + + File::deleteDirectory($sessionDir); + self::cleanupOldSessions($sessionsDir); + } + + return response()->json([ + 'status' => 'success', + 'sessionId' => $sessionId, + 'sectionName' => $sectionName, + 'chunkIndex' => $chunkIndex, + 'totalChunks' => $totalChunks, + 'isLastSection' => $isLastSection, + ]); + } + + /** + * 일괄 전송을 처리합니다 (소용량 데이터). + * + * @param Request $request 덤프 요청 + * @param string $debugDir 최종 저장 디렉토리 + * @return JsonResponse 처리 결과 + */ + public static function handleBulk(Request $request, string $debugDir): JsonResponse + { + $sections = $request->input('sections', []); + $saveHistory = $request->boolean('saveHistory'); + $timestampStr = self::formatTimestamp($request->input('timestamp')); + + $savedSections = []; + + foreach ((array) $sections as $sectionName => $sectionData) { + if (! isset(self::SECTION_FILES[$sectionName])) { + continue; + } + + $json = json_encode($sectionData, self::JSON_FLAGS); + File::put($debugDir.'/'.self::SECTION_FILES[$sectionName], $json); + $savedSections[] = $sectionName; + + if ($saveHistory && $sectionName === self::HISTORY_SECTION) { + File::put($debugDir."/state-{$timestampStr}.json", $json); + } + } + + return response()->json([ + 'status' => 'success', + 'mode' => 'bulk', + 'savedSections' => $savedSections, + 'timestamp' => $timestampStr, + ]); + } + + /** + * 레거시 전체 전송을 처리합니다 (하위 호환성 — 섹션명이 요청 최상위 키). + * + * @param Request $request 덤프 요청 + * @param string $debugDir 최종 저장 디렉토리 + * @return JsonResponse 처리 결과 + */ + public static function handleLegacy(Request $request, string $debugDir): JsonResponse + { + $timestamp = now()->format('Ymd_His'); + $saveHistory = $request->boolean('saveHistory'); + + foreach (self::SECTION_FILES as $sectionName => $fileName) { + if (! $request->has($sectionName)) { + continue; + } + + $json = json_encode($request->input($sectionName), self::JSON_FLAGS); + File::put($debugDir.'/'.$fileName, $json); + + if ($saveHistory && $sectionName === self::HISTORY_SECTION) { + File::put($debugDir."/state-{$timestamp}.json", $json); + } + } + + return response()->json([ + 'status' => 'success', + 'path' => $debugDir.'/state-latest.json', + 'timestamp' => $timestamp, + ]); + } + + /** + * 세션 디렉토리의 섹션 파일들을 최종 저장 위치로 옮깁니다. + * + * @param string $sessionDir 세션 임시 디렉토리 + * @param string $debugDir 최종 저장 디렉토리 + * @param bool $saveHistory 이력 파일 저장 여부 + * @param mixed $timestamp 클라이언트가 보낸 밀리초 타임스탬프 + * @return void + */ + private static function mergeSectionsToFinalFiles( + string $sessionDir, + string $debugDir, + bool $saveHistory, + mixed $timestamp + ): void { + $timestampStr = self::formatTimestamp($timestamp); + + foreach (File::files($sessionDir) as $file) { + $sectionName = pathinfo($file, PATHINFO_FILENAME); + + if (! isset(self::SECTION_FILES[$sectionName])) { + continue; + } + + File::copy($file, $debugDir.'/'.self::SECTION_FILES[$sectionName]); + + if ($saveHistory && $sectionName === self::HISTORY_SECTION) { + File::copy($file, $debugDir."/state-{$timestampStr}.json"); + } + } + } + + /** + * 오래된 세션 디렉토리를 정리합니다. + * + * @param string $sessionsDir 세션 루트 디렉토리 + * @return void + */ + private static function cleanupOldSessions(string $sessionsDir): void + { + if (! File::isDirectory($sessionsDir)) { + return; + } + + $cutoffTime = time() - self::SESSION_TTL_SECONDS; + + foreach (File::directories($sessionsDir) as $sessionDir) { + if (File::lastModified($sessionDir) < $cutoffTime) { + File::deleteDirectory($sessionDir); + } + } + } + + /** + * 클라이언트 타임스탬프(밀리초)를 이력 파일명용 문자열로 변환합니다. + * + * `date()` 의 두 번째 파라미터는 int 이므로 `$timestamp / 1000` 의 float 결과를 그대로 + * 넘기면 PHP 8.1+ 에서 암묵 형변환 deprecation 이 발생한다 — 명시적으로 캐스팅한다. + * + * @param mixed $timestamp 밀리초 타임스탬프 (없으면 현재 시각 사용) + * @return string `Ymd_His` 형식 문자열 + */ + private static function formatTimestamp(mixed $timestamp): string + { + if (! is_numeric($timestamp)) { + return now()->format('Ymd_His'); + } + + return date('Ymd_His', (int) ($timestamp / 1000)); + } +} diff --git a/app/Support/DevTools/DebugGate.php b/app/Support/DevTools/DebugGate.php new file mode 100644 index 00000000..cae2cb71 --- /dev/null +++ b/app/Support/DevTools/DebugGate.php @@ -0,0 +1,41 @@ +get('debug.mode', false); + } +} diff --git a/docs/ai-tools/devtools/README.md b/docs/ai-tools/devtools/README.md index 9e65b770..d391c690 100644 --- a/docs/ai-tools/devtools/README.md +++ b/docs/ai-tools/devtools/README.md @@ -290,9 +290,25 @@ resources/js/core/devtools/ # 브라우저 측 DevTools 코어 └── ui/ # DevTools 패널 UI (React) routes/devtools.php # 상태 덤프 API 엔드포인트 +app/Support/DevTools/ # 엔드포인트 실제 구현 (덤프 기록 · 브라우저 로그 · 디버그 게이트) storage/debug-dump/ # 디버그 데이터 저장 (자동 생성, Git 제외) ``` +라우트 파일은 위임만 하고 로직은 `app/Support/DevTools/` 의 클래스가 갖는다. 라우트 캐시가 +걸리면 라우트 파일이 로드되지 않으므로, 파일 안에 정의된 심볼은 핸들러가 호출하는 순간 +사라진다 (상세: [routing.md "캐시 안전한 라우트 작성"](../../backend/routing.md)). + +### 브라우저 콘솔 로그 수집 (`_boost/browser-logs`) + +이 엔드포인트는 원래 Laravel Boost 패키지가 등록했으나 G7 가 `routes/devtools.php` 에서 직접 +소유한다. 패키지는 서비스 프로바이더 부팅 시점에 라우트를 등록하는데, 라우트 캐시가 있으면 +그 시점의 등록이 캐시 교체로 폐기되어 엔드포인트가 사라졌다. 그 결과 브라우저가 보낸 로그 +요청이 조용히 실패하고 — 실패는 스크립트 안에서 삼켜져 화면에도 로그에도 아무 흔적이 남지 +않는다 — `storage/logs/browser.log` 만 비어 있었다. + +URI 는 `_boost/browser-logs` 로 고정한다. 이미 배포된 주입 스크립트가 이 주소를 사용하므로 +바꾸면 로그 수집이 다시 끊긴다. 디버그 모드가 꺼져 있으면 403 을 반환한다. + --- ## 트러블슈팅 diff --git a/docs/backend/routing.md b/docs/backend/routing.md index a2f6490b..e65153b5 100644 --- a/docs/backend/routing.md +++ b/docs/backend/routing.md @@ -362,6 +362,32 @@ Route::prefix('products')->group(function () { 서버 라우트에 영향을 주지 않는다. 모듈 설정의 경로 값도 마찬가지다(서버 라우트 접두사는 `api/modules/{identifier}` 로 식별자에 고정). +### 캐시 안전한 라우트 작성 + +캐시가 걸리면 `RouteServiceProvider::boot()` 이 캐시 파일 로드로 분기해 **라우트 파일 자체가 +실행되지 않는다.** 라우트 핸들러 클로저는 직렬화된 형태로 복원되지만, 파일이 실행되어야만 +존재하는 심볼은 어디에도 없다. + +깨지는 것은 클로저가 아니라 **오토로드되지 않는 심볼 참조**다. 클로저 자체는 캐시에서 정상 +동작한다 — `routes/web.php` 의 SPA catch-all 이 그 증거다. 클래스는 오토로더가 찾아주므로 +FQCN 정적 호출도 안전하다. 문제가 되는 것은 오토로드 경로가 없는 것들뿐이다. + +| ❌ 금지 | ✅ 올바른 사용 | +|--------|---------------| +| 라우트 파일에 전역 함수를 선언하고 핸들러가 호출 | 로직을 클래스(`app/Support/…` 등)로 옮기고 핸들러는 위임만 | +| 파일 스코프 변수를 핸들러가 `use` 없이 참조 | 클래스 상수 또는 `use ($var)` 로 클로저에 캡처 | +| 벤더/서비스 프로바이더가 조건부로 등록하는 라우트에 의존 | 그 URI 를 G7 라우트 파일이 직접 소유 | + +전역 함수 위반의 증상은 특히 추적하기 어렵다. `Call to undefined function` 500 이 나는데 +예외의 `file` 이 `laravel-serializable-closure://` 라 **원인 파일이 스택에 드러나지 않는다.** +정적 검사가 라우트 파일의 함수 선언을 차단한다. + +프로바이더의 `boot()` 에서 등록한 라우트가 폐기되는 이유는 별개다. `Router::setCompiledRoutes()` +가 라우트 컬렉션을 **통째로 교체**하고 이 교체가 `booted` 콜백에서 일어나므로, 그보다 앞서 +등록된 것은 조건 충족 여부와 무관하게 사라진다. 프레임워크 자신의 `BroadcastManager::routes()` +는 캐시 여부를 확인하는 가드를 갖고 있지만, 모든 패키지가 그렇지는 않다. 그런 라우트에 +기능이 의존한다면 해당 URI 를 G7 라우트 파일에서 등록해 캐시에 함께 구워지게 한다. + --- ## 개발 체크리스트 diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index e95621d5..553084e9 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.11] - 2026-08-07 + +### Added + +- 개발자 도구 응답 문구 일본어 번역 추가 — 디버그 모드 접근 거부, 연결 확인, 덤프 데이터 없음 안내, 데이터 삭제 완료 안내가 일본어 로케일에서 번역문으로 표시됩니다. 종전에는 사이트 언어와 무관하게 항상 한국어로만 나왔습니다. + ## [1.0.10] - 2026-08-04 ### Added diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/devtools.php b/lang-packs/_bundled/g7-core-ja/backend/ja/devtools.php new file mode 100644 index 00000000..6f1b14c2 --- /dev/null +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/devtools.php @@ -0,0 +1,11 @@ + 'デバッグモードが無効化されています。', + 'connection_ok' => '接続に成功しました。', + 'no_state' => '状態ダンプファイルがありません。ブラウザーで G7DevTools.server.dumpState() を実行してください。', + 'no_actions' => 'アクション履歴ファイルがありません。', + 'no_cache' => 'キャッシュ統計ファイルがありません。', + 'no_change_detection' => '変更検知データがありません。ブラウザーで G7DevTools.server.dumpState() を実行してください。', + 'cleared' => 'デバッグデータが削除されました。', +]; diff --git a/lang-packs/_bundled/g7-core-ja/language-pack.json b/lang-packs/_bundled/g7-core-ja/language-pack.json index fa5cb272..713db405 100644 --- a/lang-packs/_bundled/g7-core-ja/language-pack.json +++ b/lang-packs/_bundled/g7-core-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 core Japanese language pack (bundled)", "ja": "G7 コア 日本語 言語パック(バンドル)" }, - "version": "1.0.10", + "version": "1.0.11", "license": "MIT", "scope": "core", "target_identifier": null, diff --git a/lang/en/devtools.php b/lang/en/devtools.php new file mode 100644 index 00000000..63e4eac0 --- /dev/null +++ b/lang/en/devtools.php @@ -0,0 +1,19 @@ + 'Debug mode is disabled.', + 'connection_ok' => 'Connected', + + // Dump data lookup (for MCP tools) + 'no_state' => 'No state dump file found. Run G7DevTools.server.dumpState() in the browser.', + 'no_actions' => 'No action history file found.', + 'no_cache' => 'No cache statistics file found.', + 'no_change_detection' => 'No change detection data found. Run G7DevTools.server.dumpState() in the browser.', + + // Data removal + 'cleared' => 'Debug data has been cleared.', +]; diff --git a/lang/ko/devtools.php b/lang/ko/devtools.php new file mode 100644 index 00000000..c346a0a7 --- /dev/null +++ b/lang/ko/devtools.php @@ -0,0 +1,19 @@ + '디버그 모드가 비활성화되어 있습니다.', + 'connection_ok' => '연결 성공', + + // 덤프 데이터 조회 (MCP 도구용) + 'no_state' => '상태 덤프 파일이 없습니다. 브라우저에서 G7DevTools.server.dumpState()를 실행하세요.', + 'no_actions' => '액션 이력 파일이 없습니다.', + 'no_cache' => '캐시 통계 파일이 없습니다.', + 'no_change_detection' => '변경 감지 데이터가 없습니다. 브라우저에서 G7DevTools.server.dumpState()를 실행하세요.', + + // 데이터 삭제 + 'cleared' => '디버그 데이터가 삭제되었습니다.', +]; diff --git a/routes/devtools.php b/routes/devtools.php index 3a53ce00..420faea0 100644 --- a/routes/devtools.php +++ b/routes/devtools.php @@ -5,8 +5,23 @@ * * 디버깅 데이터 덤프 및 로그 전송 엔드포인트 * 디버그 모드가 활성화된 경우에만 동작 + * + * 라우트 캐시 안전성: + * `route:cache` 가 걸리면 이 파일은 **로드되지 않는다** — `RouteServiceProvider::boot()` 이 + * `loadCachedRoutes()` 로 분기하고 클로저만 `SerializableClosure` 로 복원된다. 따라서 이 + * 파일 안에 전역 함수나 파일 스코프 변수를 정의하고 핸들러에서 참조하면 캐시 상태에서 + * `Call to undefined function` 500 이 된다. 실제 로직은 전부 오토로드되는 클래스 + * (`App\Support\DevTools\*`) 에 두고, 여기서는 위임만 한다. + * + * 반면 클래스 호출은 `use` 별칭이든 FQCN 이든 안전하다. `SerializableClosure` 는 **직렬화 + * 시점**(= `route:cache` 실행 중, 이 파일이 로드되어 있는 시점) 에 소스를 파싱해 import 를 + * 해석하고 완전 수식명으로 치환한 코드를 굽는다. 역직렬화 때는 해석할 import 가 남아 있지 + * 않다. `DevtoolsRouteCacheTest` 가 캐시된 클로저로 이 위임들을 실제 호출해 검증한다. */ +use App\Support\DevTools\BrowserLogWriter; +use App\Support\DevTools\DebugDumpWriter; +use App\Support\DevTools\DebugGate; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\File; @@ -14,451 +29,38 @@ use Illuminate\Support\Facades\Route; /* |-------------------------------------------------------------------------- -| DevTools Helper Functions +| Browser Logs (boost.browser-logs) |-------------------------------------------------------------------------- | -| 섹션별 분할 전송 처리를 위한 헬퍼 함수들 +| 이 라우트는 원래 `Laravel\Boost\BoostServiceProvider::boot()` 이 등록한다. +| 그런데 `Router::setCompiledRoutes()` 는 `booted` 콜백에서 라우트 컬렉션을 통째로 +| 교체하므로, 라우트 캐시가 있으면 프로바이더 boot() 에서 등록한 라우트는 조건 충족 +| 여부와 무관하게 전부 폐기된다. 그 결과 `routes/web.php` 의 SPA catch-all(GET 전용)만 +| 남아 POST 가 405 로 튕기고, `BrowserLogger` 는 `Route::has('boost.browser-logs')` 가 +| false 라 폴백 리터럴 URL 을 굽는다 — fetch 실패는 `.catch` 로 삼켜져 오류 한 줄 없이 +| 브라우저 로그 수집만 사라진다. +| +| 그래서 G7 가 이 URI 를 직접 소유해 캐시에 함께 구워지게 한다. "boost 가 등록하는데 왜 +| 중복으로 두었나" 라는 오해로 제거하지 말 것. 캐시가 없을 때는 boost 도 같은 URI 를 +| 등록하지만 `RouteCollection::addToCollections()` 가 method+URI 키로 덮어쓰므로 물리적 +| 라우트는 1개만 남는다. +| +| URI 는 `_boost/browser-logs` 고정이다 — `BrowserLogger` 의 폴백 리터럴 및 이미 배포된 +| 인젝션 스크립트와 일치해야 한다. CSRF 면제는 불필요하다: `bootstrap/app.php` 가 이 파일을 +| `api` 그룹으로 감싸므로 `VerifyCsrfToken` 이 애초에 걸리지 않는다. | */ -if (! function_exists('handleSectionalDump')) { - /** - * 섹션별 청크 분할 전송 처리 - */ - function handleSectionalDump(Request $request, string $debugDir, string $sessionsDir): JsonResponse - { - $sessionId = $request->input('sessionId'); - $sectionName = $request->input('sectionName'); - $chunkData = $request->input('chunkData'); - $chunkIndex = (int) $request->input('chunkIndex', 0); - $totalChunks = (int) $request->input('totalChunks', 1); - $isLastChunk = $request->boolean('isLastChunk'); - $isLastSection = $request->boolean('isLastSection'); - $timestamp = $request->input('timestamp'); - $saveHistory = $request->boolean('saveHistory'); - $totalSections = $request->input('totalSections', 1); - - // 세션 디렉토리 생성 - $sessionDir = $sessionsDir.'/'.$sessionId; - if (! File::isDirectory($sessionDir)) { - File::makeDirectory($sessionDir, 0755, true); - } - - // 청크를 임시 파일에 저장 - $chunkFile = $sessionDir.'/'.$sectionName.'.chunk.'.$chunkIndex; - File::put($chunkFile, $chunkData); - - // 해당 섹션의 마지막 청크이면 모든 청크를 병합 - if ($isLastChunk) { - $mergedData = ''; - for ($i = 0; $i < $totalChunks; $i++) { - $chunkPath = $sessionDir.'/'.$sectionName.'.chunk.'.$i; - if (File::exists($chunkPath)) { - $mergedData .= File::get($chunkPath); - File::delete($chunkPath); - } - } - - // 병합된 JSON을 섹션 파일로 저장 - $sectionFile = $sessionDir.'/'.$sectionName.'.json'; - File::put($sectionFile, $mergedData); - } - - // 전체 덤프의 마지막 섹션/청크이면 최종 파일 생성 - if ($isLastSection) { - mergeSectionsToFinalFiles($sessionDir, $debugDir, $saveHistory, $timestamp); - - // 세션 디렉토리 정리 - File::deleteDirectory($sessionDir); - - // 오래된 세션 정리 (10분 이상 된 세션) - cleanupOldSessions($sessionsDir); - } - +Route::post('_boost/browser-logs', function (Request $request): JsonResponse { + if (! DebugGate::isEnabled()) { return response()->json([ - 'status' => 'success', - 'sessionId' => $sessionId, - 'sectionName' => $sectionName, - 'chunkIndex' => $chunkIndex, - 'totalChunks' => $totalChunks, - 'isLastSection' => $isLastSection, - ]); + 'status' => 'error', + 'message' => __('devtools.debug_disabled'), + ], 403); } -} -if (! function_exists('mergeSectionsToFinalFiles')) { - /** - * 섹션 파일들을 최종 파일로 병합 - */ - function mergeSectionsToFinalFiles(string $sessionDir, string $debugDir, bool $saveHistory, $timestamp): void - { - // 섹션명 -> 파일명 매핑 - $sectionToFile = [ - 'state' => 'state-latest.json', - 'actions' => 'actions-latest.json', - 'cache' => 'cache-latest.json', - 'lifecycle' => 'lifecycle-latest.json', - 'network' => 'network-latest.json', - 'expressions' => 'expressions-latest.json', - 'forms' => 'form-latest.json', - 'performance' => 'performance-latest.json', - 'conditionals' => 'conditionals-latest.json', - 'dataSources' => 'datasources-latest.json', - 'handlers' => 'handlers-latest.json', - 'componentEvents' => 'component-events-latest.json', - 'stateRendering' => 'state-rendering-latest.json', - 'stateHierarchy' => 'state-hierarchy-latest.json', - 'contextFlow' => 'context-flow-latest.json', - 'styleValidation' => 'style-validation-latest.json', - 'authDebug' => 'auth-debug-latest.json', - 'logs' => 'logs-latest.json', - 'layout' => 'layout-latest.json', - 'changeDetection' => 'change-detection-latest.json', - 'sequenceTracking' => 'sequence-latest.json', - 'staleClosureTracking' => 'stale-closure-latest.json', - 'cacheDecisionTracking' => 'cache-decisions-latest.json', - 'dataPathTransformTracking' => 'data-path-transform-latest.json', - 'nestedContextTracking' => 'nested-context-latest.json', - 'formBindingValidationTracking' => 'form-binding-validation-latest.json', - 'computedDependencyTracking' => 'computed-dependency-latest.json', - 'modalStateScopeTracking' => 'modal-state-scope-latest.json', - 'namedActionTracking' => 'named-action-tracking-latest.json', - ]; - - $timestampStr = $timestamp ? date('Ymd_His', $timestamp / 1000) : now()->format('Ymd_His'); - - // 각 섹션 파일을 최종 위치로 이동 - foreach (File::files($sessionDir) as $file) { - $sectionName = pathinfo($file, PATHINFO_FILENAME); - - if (isset($sectionToFile[$sectionName])) { - $targetFile = $debugDir.'/'.$sectionToFile[$sectionName]; - File::copy($file, $targetFile); - - // 이력 파일 저장 (state 섹션만) - if ($saveHistory && $sectionName === 'state') { - File::copy($file, $debugDir."/state-{$timestampStr}.json"); - } - } - } - } -} - -if (! function_exists('cleanupOldSessions')) { - /** - * 오래된 세션 디렉토리 정리 (10분 이상) - */ - function cleanupOldSessions(string $sessionsDir): void - { - if (! File::isDirectory($sessionsDir)) { - return; - } - - $cutoffTime = time() - (10 * 60); // 10분 전 - - foreach (File::directories($sessionsDir) as $sessionDir) { - if (File::lastModified($sessionDir) < $cutoffTime) { - File::deleteDirectory($sessionDir); - } - } - } -} - -if (! function_exists('handleBulkDump')) { - /** - * 일괄 전송 처리 (소용량 데이터용) - * - * 모든 섹션이 단일 요청에 포함됨 - */ - function handleBulkDump(Request $request, string $debugDir): JsonResponse - { - $sections = $request->input('sections', []); - $timestamp = $request->input('timestamp'); - $saveHistory = $request->boolean('saveHistory'); - - $timestampStr = $timestamp ? date('Ymd_His', $timestamp / 1000) : now()->format('Ymd_His'); - - // 섹션명 -> 파일명 매핑 - $sectionToFile = [ - 'state' => 'state-latest.json', - 'actions' => 'actions-latest.json', - 'cache' => 'cache-latest.json', - 'lifecycle' => 'lifecycle-latest.json', - 'network' => 'network-latest.json', - 'expressions' => 'expressions-latest.json', - 'forms' => 'form-latest.json', - 'performance' => 'performance-latest.json', - 'conditionals' => 'conditionals-latest.json', - 'dataSources' => 'datasources-latest.json', - 'handlers' => 'handlers-latest.json', - 'componentEvents' => 'component-events-latest.json', - 'stateRendering' => 'state-rendering-latest.json', - 'stateHierarchy' => 'state-hierarchy-latest.json', - 'contextFlow' => 'context-flow-latest.json', - 'styleValidation' => 'style-validation-latest.json', - 'authDebug' => 'auth-debug-latest.json', - 'logs' => 'logs-latest.json', - 'layout' => 'layout-latest.json', - 'changeDetection' => 'change-detection-latest.json', - 'sequenceTracking' => 'sequence-latest.json', - 'staleClosureTracking' => 'stale-closure-latest.json', - 'cacheDecisionTracking' => 'cache-decisions-latest.json', - 'dataPathTransformTracking' => 'data-path-transform-latest.json', - 'nestedContextTracking' => 'nested-context-latest.json', - 'formBindingValidationTracking' => 'form-binding-validation-latest.json', - 'computedDependencyTracking' => 'computed-dependency-latest.json', - 'modalStateScopeTracking' => 'modal-state-scope-latest.json', - 'namedActionTracking' => 'named-action-tracking-latest.json', - ]; - - $savedSections = []; - - foreach ($sections as $sectionName => $sectionData) { - if (isset($sectionToFile[$sectionName])) { - $json = json_encode($sectionData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - $targetFile = $debugDir.'/'.$sectionToFile[$sectionName]; - File::put($targetFile, $json); - $savedSections[] = $sectionName; - - // 이력 파일 저장 (state 섹션만) - if ($saveHistory && $sectionName === 'state') { - File::put($debugDir."/state-{$timestampStr}.json", $json); - } - } - } - - return response()->json([ - 'status' => 'success', - 'mode' => 'bulk', - 'savedSections' => $savedSections, - 'timestamp' => $timestampStr, - ]); - } -} - -if (! function_exists('handleLegacyDump')) { - /** - * 레거시 전체 전송 처리 (하위 호환성) - */ - function handleLegacyDump(Request $request, string $debugDir): JsonResponse - { - $timestamp = now()->format('Ymd_His'); - - // 상태 데이터 저장 - if ($request->has('state')) { - $stateData = $request->input('state'); - $stateJson = json_encode($stateData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - - File::put($debugDir.'/state-latest.json', $stateJson); - - if ($request->boolean('saveHistory')) { - File::put($debugDir."/state-{$timestamp}.json", $stateJson); - } - } - - // 액션 이력 저장 - if ($request->has('actions')) { - $actionsData = $request->input('actions'); - $actionsJson = json_encode($actionsData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/actions-latest.json', $actionsJson); - } - - // 캐시 통계 저장 - if ($request->has('cache')) { - $cacheData = $request->input('cache'); - $cacheJson = json_encode($cacheData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/cache-latest.json', $cacheJson); - } - - // 라이프사이클 정보 저장 - if ($request->has('lifecycle')) { - $lifecycleData = $request->input('lifecycle'); - $lifecycleJson = json_encode($lifecycleData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/lifecycle-latest.json', $lifecycleJson); - } - - // 네트워크 정보 저장 - if ($request->has('network')) { - $networkData = $request->input('network'); - $networkJson = json_encode($networkData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/network-latest.json', $networkJson); - } - - // 표현식 정보 저장 - if ($request->has('expressions')) { - $expressionsData = $request->input('expressions'); - $expressionsJson = json_encode($expressionsData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/expressions-latest.json', $expressionsJson); - } - - // 폼 정보 저장 - if ($request->has('forms')) { - $formsData = $request->input('forms'); - $formsJson = json_encode($formsData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/form-latest.json', $formsJson); - } - - // 성능 정보 저장 - if ($request->has('performance')) { - $performanceData = $request->input('performance'); - $performanceJson = json_encode($performanceData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/performance-latest.json', $performanceJson); - } - - // 조건부 렌더링 정보 저장 - if ($request->has('conditionals')) { - $conditionalsData = $request->input('conditionals'); - $conditionalsJson = json_encode($conditionalsData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/conditionals-latest.json', $conditionalsJson); - } - - // 데이터소스 정보 저장 - if ($request->has('dataSources')) { - $dataSourcesData = $request->input('dataSources'); - $dataSourcesJson = json_encode($dataSourcesData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/datasources-latest.json', $dataSourcesJson); - } - - // 핸들러 정보 저장 - if ($request->has('handlers')) { - $handlersData = $request->input('handlers'); - $handlersJson = json_encode($handlersData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/handlers-latest.json', $handlersJson); - } - - // 컴포넌트 이벤트 정보 저장 - if ($request->has('componentEvents')) { - $componentEventsData = $request->input('componentEvents'); - $componentEventsJson = json_encode($componentEventsData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/component-events-latest.json', $componentEventsJson); - } - - // 상태-렌더링 정보 저장 - if ($request->has('stateRendering')) { - $stateRenderingData = $request->input('stateRendering'); - $stateRenderingJson = json_encode($stateRenderingData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/state-rendering-latest.json', $stateRenderingJson); - } - - // 상태 계층 정보 저장 - if ($request->has('stateHierarchy')) { - $stateHierarchyData = $request->input('stateHierarchy'); - $stateHierarchyJson = json_encode($stateHierarchyData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/state-hierarchy-latest.json', $stateHierarchyJson); - } - - // 컨텍스트 플로우 정보 저장 - if ($request->has('contextFlow')) { - $contextFlowData = $request->input('contextFlow'); - $contextFlowJson = json_encode($contextFlowData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/context-flow-latest.json', $contextFlowJson); - } - - // 스타일 검증 정보 저장 - if ($request->has('styleValidation')) { - $styleValidationData = $request->input('styleValidation'); - $styleValidationJson = json_encode($styleValidationData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/style-validation-latest.json', $styleValidationJson); - } - - // 인증 디버깅 정보 저장 - if ($request->has('authDebug')) { - $authDebugData = $request->input('authDebug'); - $authDebugJson = json_encode($authDebugData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/auth-debug-latest.json', $authDebugJson); - } - - // 로그 정보 저장 - if ($request->has('logs')) { - $logsData = $request->input('logs'); - $logsJson = json_encode($logsData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/logs-latest.json', $logsJson); - } - - // 레이아웃 정보 저장 - if ($request->has('layout')) { - $layoutData = $request->input('layout'); - $layoutJson = json_encode($layoutData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/layout-latest.json', $layoutJson); - } - - // 변경 감지 정보 저장 - if ($request->has('changeDetection')) { - $changeDetectionData = $request->input('changeDetection'); - $changeDetectionJson = json_encode($changeDetectionData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/change-detection-latest.json', $changeDetectionJson); - } - - // Sequence 추적 정보 저장 - if ($request->has('sequenceTracking')) { - $sequenceTrackingData = $request->input('sequenceTracking'); - $sequenceTrackingJson = json_encode($sequenceTrackingData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/sequence-latest.json', $sequenceTrackingJson); - } - - // Stale Closure 추적 정보 저장 - if ($request->has('staleClosureTracking')) { - $staleClosureTrackingData = $request->input('staleClosureTracking'); - $staleClosureTrackingJson = json_encode($staleClosureTrackingData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/stale-closure-latest.json', $staleClosureTrackingJson); - } - - // 캐시 결정 추적 정보 저장 - if ($request->has('cacheDecisionTracking')) { - $cacheDecisionTrackingData = $request->input('cacheDecisionTracking'); - $cacheDecisionTrackingJson = json_encode($cacheDecisionTrackingData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/cache-decisions-latest.json', $cacheDecisionTrackingJson); - } - - // 데이터 경로 변환 추적 정보 저장 - if ($request->has('dataPathTransformTracking')) { - $dataPathTransformTrackingData = $request->input('dataPathTransformTracking'); - $dataPathTransformTrackingJson = json_encode($dataPathTransformTrackingData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/data-path-transform-latest.json', $dataPathTransformTrackingJson); - } - - // Nested Context 추적 정보 저장 - if ($request->has('nestedContextTracking')) { - $nestedContextTrackingData = $request->input('nestedContextTracking'); - $nestedContextTrackingJson = json_encode($nestedContextTrackingData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/nested-context-latest.json', $nestedContextTrackingJson); - } - - // Form 바인딩 검증 추적 정보 저장 - if ($request->has('formBindingValidationTracking')) { - $formBindingValidationTrackingData = $request->input('formBindingValidationTracking'); - $formBindingValidationTrackingJson = json_encode($formBindingValidationTrackingData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/form-binding-validation-latest.json', $formBindingValidationTrackingJson); - } - - // Computed 의존성 추적 정보 저장 - if ($request->has('computedDependencyTracking')) { - $computedDependencyTrackingData = $request->input('computedDependencyTracking'); - $computedDependencyTrackingJson = json_encode($computedDependencyTrackingData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/computed-dependency-latest.json', $computedDependencyTrackingJson); - } - - // 모달 상태 스코프 추적 정보 저장 - if ($request->has('modalStateScopeTracking')) { - $modalStateScopeTrackingData = $request->input('modalStateScopeTracking'); - $modalStateScopeTrackingJson = json_encode($modalStateScopeTrackingData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/modal-state-scope-latest.json', $modalStateScopeTrackingJson); - } - - // Named Action 추적 정보 저장 - if ($request->has('namedActionTracking')) { - $namedActionTrackingData = $request->input('namedActionTracking'); - $namedActionTrackingJson = json_encode($namedActionTrackingData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - File::put($debugDir.'/named-action-tracking-latest.json', $namedActionTrackingJson); - } - - return response()->json([ - 'status' => 'success', - 'path' => $debugDir.'/state-latest.json', - 'timestamp' => $timestamp, - ]); - } -} + return BrowserLogWriter::handle($request); +})->name('boost.browser-logs'); /* |-------------------------------------------------------------------------- @@ -478,13 +80,10 @@ Route::prefix('_boost/g7-debug')->middleware('api')->group(function () { * 섹션별 분할 전송을 지원합니다. */ Route::post('dump-state', function (Request $request): JsonResponse { - // 디버그 모드 확인 (환경설정 또는 .env) - $debugMode = config('app.debug') || \App\Models\Setting::getValue('debug.mode', false); - - if (! $debugMode) { + if (! DebugGate::isEnabled()) { return response()->json([ 'status' => 'error', - 'message' => '디버그 모드가 비활성화되어 있습니다.', + 'message' => __('devtools.debug_disabled'), ], 403); } @@ -492,30 +91,29 @@ Route::prefix('_boost/g7-debug')->middleware('api')->group(function () { if ($request->boolean('test')) { return response()->json([ 'status' => 'success', - 'message' => '연결 성공', + 'message' => __('devtools.connection_ok'), ]); } $debugDir = storage_path('debug-dump'); $sessionsDir = $debugDir.'/sessions'; - // 디렉토리 생성 if (! File::isDirectory($debugDir)) { File::makeDirectory($debugDir, 0755, true); } // 일괄 전송 처리 (소용량 데이터) if ($request->boolean('bulk') && $request->has('sections')) { - return handleBulkDump($request, $debugDir); + return DebugDumpWriter::handleBulk($request, $debugDir); } // 섹션별 분할 전송 처리 (대용량 데이터) if ($request->has('sessionId') && $request->has('sectionName')) { - return handleSectionalDump($request, $debugDir, $sessionsDir); + return DebugDumpWriter::handleSectional($request, $debugDir, $sessionsDir); } // 레거시 전체 전송 처리 (하위 호환성) - return handleLegacyDump($request, $debugDir); + return DebugDumpWriter::handleLegacy($request, $debugDir); })->name('devtools.dump-state'); /** @@ -524,13 +122,10 @@ Route::prefix('_boost/g7-debug')->middleware('api')->group(function () { * 디버그 로그, 에러, 프로파일 데이터를 저장합니다. */ Route::post('log', function (Request $request): JsonResponse { - // 디버그 모드 확인 - $debugMode = config('app.debug') || \App\Models\Setting::getValue('debug.mode', false); - - if (! $debugMode) { + if (! DebugGate::isEnabled()) { return response()->json([ 'status' => 'error', - 'message' => '디버그 모드가 비활성화되어 있습니다.', + 'message' => __('devtools.debug_disabled'), ], 403); } @@ -593,7 +188,7 @@ Route::prefix('_boost/g7-debug')->middleware('api')->group(function () { if (! File::exists($stateFile)) { return response()->json([ 'status' => 'no_data', - 'message' => '상태 덤프 파일이 없습니다. 브라우저에서 G7DevTools.server.dumpState()를 실행하세요.', + 'message' => __('devtools.no_state'), ]); } @@ -616,7 +211,7 @@ Route::prefix('_boost/g7-debug')->middleware('api')->group(function () { if (! File::exists($actionsFile)) { return response()->json([ 'status' => 'no_data', - 'message' => '액션 이력 파일이 없습니다.', + 'message' => __('devtools.no_actions'), ]); } @@ -654,7 +249,7 @@ Route::prefix('_boost/g7-debug')->middleware('api')->group(function () { if (! File::exists($cacheFile)) { return response()->json([ 'status' => 'no_data', - 'message' => '캐시 통계 파일이 없습니다.', + 'message' => __('devtools.no_cache'), ]); } @@ -676,7 +271,7 @@ Route::prefix('_boost/g7-debug')->middleware('api')->group(function () { if (! File::exists($changeDetectionFile)) { return response()->json([ 'status' => 'no_data', - 'message' => '변경 감지 데이터가 없습니다. 브라우저에서 G7DevTools.server.dumpState()를 실행하세요.', + 'message' => __('devtools.no_change_detection'), ]); } @@ -727,7 +322,7 @@ Route::prefix('_boost/g7-debug')->middleware('api')->group(function () { return response()->json([ 'status' => 'success', - 'message' => '디버그 데이터가 삭제되었습니다.', + 'message' => __('devtools.cleared'), ]); })->name('devtools.clear'); }); diff --git a/tests/Feature/DevTools/DevtoolsDebugGateTest.php b/tests/Feature/DevTools/DevtoolsDebugGateTest.php new file mode 100644 index 00000000..641cb488 --- /dev/null +++ b/tests/Feature/DevTools/DevtoolsDebugGateTest.php @@ -0,0 +1,116 @@ + false]); + $this->mockConfigRepositoryDebugMode(true); + + $response = $this->postJson('/_boost/g7-debug/dump-state', ['test' => true]); + + $response->assertStatus(200); + $response->assertJson(['status' => 'success']); + } + + /** + * 양쪽 모두 꺼져 있으면 403 이어야 한다 (500 이 아니라). + */ + public function test_dump_state_returns_403_when_debug_disabled(): void + { + config(['app.debug' => false]); + $this->mockConfigRepositoryDebugMode(false); + + $response = $this->postJson('/_boost/g7-debug/dump-state', ['test' => true]); + + $response->assertStatus(403); + $response->assertJson(['status' => 'error']); + + // 번역 키가 해석되지 않으면 Laravel 은 키 문자열을 그대로 돌려준다 — 오류 없이 + // 사용자에게 `devtools.debug_disabled` 가 노출되므로 해석 여부를 단언한다. + $this->assertNotSame( + 'devtools.debug_disabled', + $response->json('message'), + '번역 키가 해석되지 않았습니다 (lang/{ko,en}/devtools.php 누락).' + ); + } + + /** + * `browser-logs` 도 동일 게이트를 사용한다. + */ + public function test_browser_logs_returns_403_when_debug_disabled(): void + { + config(['app.debug' => false]); + $this->mockConfigRepositoryDebugMode(false); + + $response = $this->postJson('/_boost/browser-logs', ['logs' => []]); + + $response->assertStatus(403); + } + + /** + * `.env` 의 `APP_DEBUG=true` 만으로도 통과한다 (환경설정 조회 불필요). + */ + public function test_gate_passes_on_app_debug_without_settings_lookup(): void + { + config(['app.debug' => true]); + + $repository = Mockery::mock(ConfigRepositoryInterface::class); + $repository->shouldNotReceive('get'); + $this->app->instance(ConfigRepositoryInterface::class, $repository); + + $this->assertTrue(DebugGate::isEnabled()); + } + + /** + * `ConfigRepositoryInterface` 를 `debug.mode` 고정값으로 대체한다. + * + * @param bool $enabled debug.mode 값 + * @return void + */ + private function mockConfigRepositoryDebugMode(bool $enabled): void + { + $repository = Mockery::mock(ConfigRepositoryInterface::class); + $repository->shouldReceive('get') + ->with('debug.mode', false) + ->andReturn($enabled); + $repository->shouldReceive('get')->andReturnUsing( + static fn (string $key, mixed $default = null): mixed => $default + ); + + $this->app->instance(ConfigRepositoryInterface::class, $repository); + } +} diff --git a/tests/Feature/DevTools/DevtoolsRouteCacheTest.php b/tests/Feature/DevTools/DevtoolsRouteCacheTest.php new file mode 100644 index 00000000..bbd7f0ab --- /dev/null +++ b/tests/Feature/DevTools/DevtoolsRouteCacheTest.php @@ -0,0 +1,392 @@ +dispatch('POST', '/_boost/g7-debug/dump-state', json_encode([ + self::PROBE_SECTION => ['marker' => $marker], + ], JSON_UNESCAPED_UNICODE)); + + $this->assertTrue( + $result['routesAreCached'], + '라우트 캐시가 걸리지 않은 채 통과하면 이 테스트는 아무것도 검증하지 못합니다 (가짜 green).' + ); + $this->assertStringContainsString( + 'route-cache-', + $result['cachedRoutesPath'], + '자식 프로세스가 임시 캐시가 아니라 실 캐시를 사용했습니다.' + ); + + $this->assertSame( + 200, + $result['status'], + "라우트 캐시 상태에서 dump-state 가 200 이어야 합니다.\nbody: {$result['body']}" + ); + $this->assertStringNotContainsString('undefined function', $result['body']); + + // 200 만으로는 부족하다 — 페이로드가 비어 있어도 legacy 경로는 200 을 돌려준다. + // 위임(DebugDumpWriter)이 실제로 수행됐음을 파일로 확인한다. + $probePath = base_path('storage/debug-dump/'.self::PROBE_FILE); + + $this->assertFileExists($probePath, '덤프 위임이 파일을 쓰지 않았습니다.'); + $this->assertStringContainsString( + $marker, + (string) file_get_contents($probePath), + '덤프 파일에 이번 요청의 페이로드가 담기지 않았습니다 (본문이 자식 프로세스에 전달되지 않음).' + ); + } + + /** + * `_boost/browser-logs` 는 라우트 캐시가 걸린 상태에서도 POST 를 받아야 한다. + * + * 수정 전: HTTP 405 — boost 프로바이더가 boot() 에서 등록한 라우트가 캐시 교체로 폐기되고, + * `routes/web.php` 의 SPA catch-all(GET 전용)만 남아 POST 를 튕겨낸다. + */ + public function test_boost_browser_logs_survives_route_cache(): void + { + $result = $this->dispatch('POST', '/_boost/browser-logs', json_encode([ + 'logs' => [[ + 'type' => 'log', + 'timestamp' => '2026-01-01T00:00:00+00:00', + 'data' => ['harness'], + 'url' => 'https://example.test/harness', + 'userAgent' => 'harness', + ]], + ], JSON_UNESCAPED_UNICODE)); + + $this->assertTrue( + $result['routesAreCached'], + '라우트 캐시가 걸리지 않은 채 통과하면 이 테스트는 아무것도 검증하지 못합니다 (가짜 green).' + ); + $this->assertStringContainsString('route-cache-', $result['cachedRoutesPath']); + + $this->assertSame( + 200, + $result['status'], + "라우트 캐시 상태에서 browser-logs 가 200 이어야 합니다.\nbody: {$result['body']}" + ); + } + + /** + * 캐시된 클로저 안에서 `__()` 번역 헬퍼가 해석되어야 한다. + * + * 전역 함수와 달리 `__()` 는 Composer 의 `files` 오토로드로 등록되므로 라우트 파일 로드 + * 여부와 무관하다 — 다만 "라우트 파일에 있던 것은 캐시에서 사라진다" 는 이 이슈의 성질상 + * 실제로 해석되는지를 단언해 둔다. 미해석 시 Laravel 은 오류 없이 키 문자열을 그대로 + * 응답에 실으므로, 단언이 없으면 `devtools.connection_ok` 노출을 알아챌 방법이 없다. + */ + public function test_translation_helper_resolves_inside_cached_closure(): void + { + $result = $this->dispatch('POST', '/_boost/g7-debug/dump-state', json_encode([ + 'test' => true, + ], JSON_UNESCAPED_UNICODE)); + + $this->assertTrue($result['routesAreCached']); + $this->assertSame(200, $result['status'], "body: {$result['body']}"); + + $payload = json_decode($result['body'], true); + + $this->assertIsArray($payload, "응답 JSON 파싱 실패.\nbody: {$result['body']}"); + $this->assertNotSame( + 'devtools.connection_ok', + $payload['message'] ?? null, + '캐시된 클로저 안에서 번역 키가 해석되지 않았습니다 (키 문자열이 그대로 노출됨).' + ); + $this->assertNotEmpty($payload['message'] ?? null); + } + + /** + * 라우트 파일은 전역 함수를 선언하지 않아야 한다 (in-process, audit 룰과 이중화). + * + * 라우트 캐시가 걸리면 라우트 파일이 로드되지 않으므로, 파일 안에 정의된 전역 함수는 + * 존재하지 않는다. 핸들러가 그것을 호출하면 즉사한다. + */ + public function test_devtools_route_file_declares_no_global_functions(): void + { + $path = base_path('routes/devtools.php'); + $source = file_get_contents($path); + + $this->assertIsString($source); + + preg_match_all('/^\s*function\s+(\w+)\s*\(/m', $source, $matches); + + $this->assertSame( + [], + $matches[1], + 'routes/devtools.php 에 전역 함수가 선언되어 있습니다: '.implode(', ', $matches[1]) + .'. 라우트 캐시가 걸리면 이 파일은 로드되지 않아 핸들러가 이 함수를 찾지 못합니다 ' + .'(→ Call to undefined function). 클래스로 옮겨 오토로드 대상으로 만드세요.' + ); + } + + /** + * 자식 프로세스가 실 `bootstrap/cache/routes-v7.php` 를 건드리지 않아야 한다. + * + * 실 캐시가 testing 기준으로 재생성되면 개발 환경이 통째로 깨진다. + */ + public function test_real_bootstrap_cache_is_untouched(): void + { + // 앞선 테스트들이 자식 프로세스를 이미 띄웠음을 보장한다. + $this->bakeOnce(); + + $realCachePath = base_path('bootstrap/cache/routes-v7.php'); + + $this->assertSame( + self::$realCacheExisted, + file_exists($realCachePath), + '자식 프로세스가 실 라우트 캐시를 생성하거나 삭제했습니다.' + ); + + if (self::$realCacheExisted) { + $this->assertSame( + self::$realCacheDigest, + md5_file($realCachePath), + '자식 프로세스가 실 라우트 캐시를 덮어썼습니다 (임시 캐시 경로 주입 실패).' + ); + } + } + + /** + * 임시 경로에 라우트 캐시를 굽는다 (클래스당 1회). + * + * @return array 하네스 bake 결과 + */ + private function bakeOnce(): array + { + if (self::$bakeResult !== null) { + return self::$bakeResult; + } + + [$exitCode, $output] = $this->runHarness(['bake', self::$cacheRelDir]); + $payload = $this->parseHarnessOutput($output); + + // bake 실패를 markTestSkipped 로 덮으면 회귀 방지가 통째로 무력화된다. + $this->assertSame( + 0, + $exitCode, + "라우트 캐시 굽기(bake)가 실패했습니다.\noutput:\n{$output}" + ); + $this->assertTrue( + $payload['cacheWritten'] ?? false, + "bake 후에도 라우트 캐시 파일이 생성되지 않았습니다.\noutput:\n{$output}" + ); + + return self::$bakeResult = $payload; + } + + /** + * 캐시된 라우트로 부팅한 자식 프로세스에 HTTP 요청을 디스패치한다. + * + * @param string $method HTTP 메서드 + * @param string $uri 요청 URI + * @param string $body JSON 본문 + * @return array 하네스 dispatch 결과 + */ + private function dispatch(string $method, string $uri, string $body = ''): array + { + $this->bakeOnce(); + + // 본문은 base64 로 넘긴다 — Windows 의 escapeshellarg() 가 큰따옴표를 공백으로 + // 치환해 JSON 이 조용히 깨지기 때문 (하네스 헤더 주석 참조). + [, $output] = $this->runHarness([ + 'dispatch', + self::$cacheRelDir, + $method, + $uri, + $body === '' ? '' : base64_encode($body), + ]); + + return $this->parseHarnessOutput($output); + } + + /** + * 하네스 스크립트를 별도 PHP 프로세스로 실행한다. + * + * @param array $args 하네스 인자 + * @return array{0: int, 1: string} [종료 코드, stdout+stderr] + */ + private function runHarness(array $args): array + { + if (! function_exists('proc_open')) { + $this->markTestSkipped('proc_open 미지원 환경'); + } + + $commandLine = implode(' ', array_map('escapeshellarg', array_merge([ + PHP_BINARY, + base_path('tests/Fixtures/DevTools/route-cache-harness.php'), + ], $args))).' 2>&1'; + + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = proc_open($commandLine, $descriptors, $pipes, base_path()); + $this->assertIsResource($process, '하네스 프로세스 실행에 실패했습니다.'); + + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + fclose($pipes[1]); + fclose($pipes[2]); + $exitCode = proc_close($process); + + return [$exitCode, (string) $stdout]; + } + + /** + * 하네스 출력에서 마커 뒤 JSON 을 추출한다. + * + * @param string $output 프로세스 출력 전문 + * @return array 파싱된 페이로드 + */ + private function parseHarnessOutput(string $output): array + { + $marker = '@@G7HARNESS@@'; + $pos = strrpos($output, $marker); + + $this->assertNotFalse( + $pos, + "하네스 출력에서 결과 마커를 찾지 못했습니다 (자식 프로세스가 부팅 중 죽었을 가능성).\noutput:\n{$output}" + ); + + $json = trim(substr($output, $pos + strlen($marker))); + $decoded = json_decode($json, true); + + $this->assertIsArray( + $decoded, + "하네스 결과 JSON 파싱에 실패했습니다.\noutput:\n{$output}" + ); + + return $decoded; + } + + /** + * 디렉토리를 재귀적으로 삭제한다 (Laravel 부트스트랩에 의존하지 않는 순수 구현). + * + * @param string $dir 삭제할 디렉토리 절대경로 + * @return void + */ + private static function removeDirectory(string $dir): void + { + if (! is_dir($dir)) { + return; + } + + foreach (scandir($dir) ?: [] as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + + $path = $dir.'/'.$entry; + is_dir($path) ? self::removeDirectory($path) : @unlink($path); + } + + @rmdir($dir); + } +} diff --git a/tests/Fixtures/DevTools/route-cache-harness.php b/tests/Fixtures/DevTools/route-cache-harness.php new file mode 100644 index 00000000..bf6fcedc --- /dev/null +++ b/tests/Fixtures/DevTools/route-cache-harness.php @@ -0,0 +1,204 @@ + + * php tests/Fixtures/DevTools/route-cache-harness.php dispatch [] + * + * 본문은 **base64 로 인코딩해서** 넘긴다. Windows 의 `escapeshellarg()` 는 인자 안의 + * 큰따옴표를 공백으로 치환하므로 JSON 을 그대로 넘기면 `{"test":true}` 가 `{ test :true}` 로 + * 깨져 도착한다. 파싱 오류도 나지 않고 그저 입력이 비어 있는 요청이 되므로, 200 만 단언하는 + * 테스트는 페이로드가 전달되지 않은 채로 통과한다 (실측으로 발견). + * + * 출력: + * 마지막 줄에 `@@G7HARNESS@@` 마커 + JSON 1줄. + * 부팅 중 deprecation 경고가 stdout 에 섞여도 파싱이 깨지지 않고, 실패 시 원문 전체를 + * assertion 메시지로 붙일 수 있다. + * - bake → exit code 전파 (캐시 굽기 실패를 프로세스 실패로 드러낸다) + * - dispatch → 항상 exit 0 (HTTP 500 과 프로세스 실패를 분리) + * + * 함정 1 — 캐시 경로는 base_path 상대 POSIX 경로만 받는다. + * `Application::normalizeCachePath()`(vendor/laravel/framework/src/Illuminate/Foundation/Application.php:1379) + * 는 `$absoluteCachePathPrefixes = ['/', '\\']`(:209) 로 시작하는 값만 절대경로로 취급한다. + * Windows 의 `C:\...` 는 여기 걸리지 않아 `basePath($env)` 로 이어붙어 엉뚱한 위치를 가리킨다. + * + * 함정 2 — 환경변수는 프로세스 env 가 아니라 `$_SERVER`/`$_ENV` 에 직접 넣는다. + * `bootstrap/app.php:62` 의 `Env::disablePutenv()` 때문에 리더가 `$_SERVER`/`$_ENV` 뿐이고, + * CLI 의 `variables_order` 기본값 `"GPCS"` 에는 `E` 가 없어 `proc_open($env)` 이 조용히 + * 무시될 수 있다. 그러면 자식이 **실 캐시를 읽고 덮어쓴다**. + * + * 5개 캐시 경로를 전부 임시 디렉토리로 돌린다: + * - APP_ROUTES_CACHE 검증 대상 + * - APP_CONFIG_CACHE 살아 있으면 자식이 개발 DB 로 부팅한다 + * - APP_PACKAGES_CACHE / APP_SERVICES_CACHE / APP_EVENTS_CACHE + * 자식이 실 경로에 쓰는 것을 차단 + * (tests/Unit/Extension/ExtensionAutoloadCacheGuardTest.php 규율) + */ +const HARNESS_MARKER = '@@G7HARNESS@@'; + +$repoRoot = dirname(__DIR__, 3); + +$argvLocal = $_SERVER['argv'] ?? []; +$mode = $argvLocal[1] ?? ''; +$cacheRelDir = $argvLocal[2] ?? ''; + +if (! in_array($mode, ['bake', 'dispatch'], true) || $cacheRelDir === '') { + fwrite(STDERR, "usage: route-cache-harness.php bake|dispatch [...]\n"); + exit(2); +} + +// 함정 1: 상대 POSIX 경로만 수용한다. +if (preg_match('#^([A-Za-z]:|[/\\\\])#', $cacheRelDir) === 1 || str_contains($cacheRelDir, '\\')) { + fwrite(STDERR, "cacheRelDir 는 base_path 상대 POSIX 경로여야 합니다: {$cacheRelDir}\n"); + exit(2); +} + +$cacheAbsDir = $repoRoot.'/'.$cacheRelDir; +if (! is_dir($cacheAbsDir) && ! @mkdir($cacheAbsDir, 0755, true) && ! is_dir($cacheAbsDir)) { + fwrite(STDERR, "캐시 임시 디렉토리 생성 실패: {$cacheAbsDir}\n"); + exit(2); +} + +// 함정 2: $_SERVER/$_ENV 에 직접 주입 (Env::disablePutenv 대응). +$envOverrides = [ + 'APP_ENV' => 'testing', + // 게이트 단축평가 통과 + 예외 본문을 JSON 으로 확보한다. + 'APP_DEBUG' => 'true', + 'APP_ROUTES_CACHE' => $cacheRelDir.'/routes-v7.php', + 'APP_CONFIG_CACHE' => $cacheRelDir.'/config.php', + 'APP_PACKAGES_CACHE' => $cacheRelDir.'/packages.php', + 'APP_SERVICES_CACHE' => $cacheRelDir.'/services.php', + 'APP_EVENTS_CACHE' => $cacheRelDir.'/events.php', +]; + +foreach ($envOverrides as $key => $value) { + $_SERVER[$key] = $value; + $_ENV[$key] = $value; +} + +require $repoRoot.'/vendor/autoload.php'; + +/** + * 결과 JSON 을 마커와 함께 출력한다. + * + * @param array $payload 출력 페이로드 + * @return void + */ +function harnessEmit(array $payload): void +{ + echo "\n".HARNESS_MARKER.json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)."\n"; +} + +if ($mode === 'bake') { + /** @var Application $app */ + $app = require $repoRoot.'/bootstrap/app.php'; + + /** @var Kernel $kernel */ + $kernel = $app->make(Kernel::class); + $kernel->bootstrap(); + + $exitCode = 0; + $error = null; + + try { + // `route:cache` 는 route:clear 후 fresh application 을 부팅해 라우트를 수집한다. + // fresh application 도 같은 프로세스의 $_SERVER 를 보므로 캐시 경로는 임시 위치 그대로다. + $exitCode = Artisan::call('route:cache'); + echo Artisan::output(); + } catch (Throwable $e) { + $exitCode = 1; + $error = get_class($e).': '.$e->getMessage(); + } + + $cachedPath = $app->getCachedRoutesPath(); + + // 여기서 `$app->routesAreCached()` 를 쓰면 안 된다 — `Application::routesAreCached()` + // (vendor Foundation/Application.php:1327) 는 결과를 컨테이너에 `routes.cached` 로 + // **메모이즈**한다. 부팅 시 `RouteServiceProvider::boot()` 이 이미 호출했고 그때는 캐시가 + // 없었으므로, 방금 1MB 짜리 캐시를 구웠어도 이 프로세스는 영원히 false 를 돌려준다. + // bake 가 확인해야 하는 것은 "부팅이 캐시를 썼는가" 가 아니라 "파일이 생겼는가" 다. + clearstatcache(true, $cachedPath); + $cacheWritten = is_file($cachedPath); + + harnessEmit([ + 'mode' => 'bake', + 'exitCode' => $exitCode, + 'error' => $error, + 'cacheWritten' => $cacheWritten, + 'cacheBytes' => $cacheWritten ? filesize($cachedPath) : 0, + 'cachedRoutesPath' => str_replace('\\', '/', $cachedPath), + ]); + + exit($exitCode === 0 && $cacheWritten ? 0 : 1); +} + +// ── dispatch ────────────────────────────────────────────────────────────── +$method = strtoupper($argvLocal[3] ?? 'GET'); +$uri = $argvLocal[4] ?? '/'; + +$encodedBody = $argvLocal[5] ?? ''; +$body = ''; + +if ($encodedBody !== '') { + $decoded = base64_decode($encodedBody, true); + + if ($decoded === false) { + fwrite(STDERR, "본문은 base64 로 인코딩해서 넘겨야 합니다.\n"); + exit(2); + } + + $body = $decoded; +} + +/** @var Application $app */ +$app = require $repoRoot.'/bootstrap/app.php'; + +/** @var Illuminate\Contracts\Http\Kernel $httpKernel */ +$httpKernel = $app->make(Illuminate\Contracts\Http\Kernel::class); + +$server = [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/json', +]; + +$request = Request::create($uri, $method, [], [], [], $server, $body === '' ? null : $body); + +$status = 0; +$responseBody = ''; +$error = null; + +try { + $response = $httpKernel->handle($request); + $status = $response->getStatusCode(); + $responseBody = (string) $response->getContent(); + $httpKernel->terminate($request, $response); +} catch (Throwable $e) { + $error = get_class($e).': '.$e->getMessage(); +} + +harnessEmit([ + 'mode' => 'dispatch', + 'method' => $method, + 'uri' => $uri, + 'routesAreCached' => $app->routesAreCached(), + 'cachedRoutesPath' => str_replace('\\', '/', $app->getCachedRoutesPath()), + 'status' => $status, + 'body' => mb_substr($responseBody, 0, 4000), + 'error' => $error, +]); + +exit(0);