Files
Gnuboard7/resources/views/partials/bootstrap-scripts.blade.php
T
HeuJung 15cd4452bf feat(core): 정적 최적화 서버 대응 자산 URL 이중 모드
nginx 의 정규식 location 은 프리픽스 location 보다 먼저 매칭되므로,
`location ~* \.(js|css|json)$` 블록이 있는 서버에서는 확장자 붙은 동적
엔드포인트가 `try_files ... /index.php` 폴백이 실행될 기회 없이 404 가 된다.
aaPanel/CyberPanel/Plesk 기본 템플릿에 들어있어 드물지 않으며, 관리자 화면조차
뜨지 않아 "서버 설정을 고치세요" 안내가 순환 참조가 된다.

관례를 깬 쪽이 G7 이므로 해소 책임도 G7 에 두고, 두 형태를 모두 서빙한 뒤
환경에 따라 택일한다. 확장자 형태는 영구 유지한다 — 제거하면 URL 을 하드코딩한
서드파티 확장이 깨진다.

- 라우트: dualSuffix / dualSuffixSegment / dualAsset 매크로로 23개 엔드포인트와
 프로브를 이중 등록. 확장자 형태를 먼저 등록한다 — 확장자 없는 쪽이 더 느슨한
 패턴이라 순서가 뒤집히면 `.json` 요청까지 삼킨다
- URL 생성: 서버 App\Support\AssetUrl, 프론트 core/support/assetUrl.ts 로 집약.
 기본 모드에서 생성 결과는 치환 이전과 문자열까지 동일하다 (쿼리 순서 포함 —
 순서가 바뀌면 의미는 같아도 HTTP 캐시 키가 갈린다)
- 자가 복구: 부트스트랩 자산 로드 실패 시 확장자 없는 형태로 1회 단방향 전환.
 역방향 금지 + 기존 재시도 예산 공유로 무한 왕복을 막는다
- 감지·전환: 인스톨러 프로브(설치 시 확정) / 관리자 환경설정 일반 탭 /
 g7:asset-url-mode. 판정은 상태코드가 아니라 매직 토큰 + Content-Type 으로 한다.
 상태코드만 보면 "404 대신 200 + 에러 HTML" 환경에서 영원히 오판한다
- 봇은 JavaScript 를 실행하지 않아 자가 복구가 닿지 않으므로, 모드 변경 시
 SEO 프리렌더 캐시를 비워 재생성시킨다

가드: audit 룰 2종(dynamic-route-static-extension / asset-url-builder-required),
Playwright 6건(정적 블록 가로채기 시뮬레이션), 루프 방지 불변식 L1~L9 전수 red 증명.

부수 정리: 양 Composer 에 중복돼 있던 확장 에셋 수집 123줄을 트레이트로 통합하고,
자산 확장자 화이트리스트 누락(.map)과 경로 정규화 우회 가능성을 함께 교정.

sir.kr 커뮤니티의 hang 님께서 제보해주셨습니다.
2026-07-23 11:34:32 +09:00

224 lines
12 KiB
PHP

{{--
코어 엔진 + 템플릿 컴포넌트 번들 부트스트랩 (재시도 + 폴백 UI)
두 번들은 `<script src>` 로 로드되므로 엔진의 fetch 재시도 래퍼가 닿지 않는다.
코어 번들이 없으면 재시도할 JS 자체가 없다(닭-달걀). 따라서 재시도를 **순수 인라인
JS** 로 구현한다. 외부 의존성 없이 동작해야 한다.
로드 실패 시 종전에는 콘솔 에러만 남고 화면은 백지였다. 3회 시도 후에도 실패하면
사용자에게 보이는 정적 폴백(새로고침 버튼)을 심는다.
성능 계약 — `<script src>` 는 **정적 태그로 유지한다**:
브라우저의 프리로드 스캐너는 HTML 파싱 중에 정적 `<script src>` 를 미리 발견해
번들을 선행 로드한다. 이를 `document.createElement('script')` 로 바꾸면 스캐너가
보지 못해 **인라인 JS 가 실행된 뒤에야** 요청이 시작되고, 그만큼 렌더가 늦어진다
(실측: HTML 응답 완료 314ms → 코어 번들 요청 시작 568ms, 약 250ms 손실).
따라서 정상 경로는 정적 태그 그대로 두고, **실패했을 때만** `onerror` 에서 동적
재시도를 건다. 재시도는 예외 경로이므로 스캐너 이점을 잃어도 무방하다.
'before-template' 외부 스크립트는 코어 뒤 · 템플릿 컴포넌트 앞이라는 순서 계약을
갖는다. 정적 태그를 유지하므로 그 사이에 그대로 @include 하면 순서가 지켜진다.
@param $templateType 'user' | 'admin'
@param $coreEngineSrc 코어 엔진 번들 URL
@param $componentsSrc 템플릿 컴포넌트 번들 URL
@param $initConfig initTemplateApp 에 넘길 설정 (JSON 직렬화 가능)
--}}
<script>
(function () {
'use strict';
var MAX_ATTEMPTS = 3; // 총 3시도 (재시도 2회)
var BASE_DELAY_MS = 300; // 지수 백오프 기준 (모바일 커넥션 재수립 시간 고려)
var LABEL = @json($templateType === 'admin' ? '[Admin]' : '[User]');
// 문서 이탈 추적 — 버려지는 문서에 에러 화면을 그리지 않기 위함
window.__g7Unloading = false;
window.addEventListener('pagehide', function () { window.__g7Unloading = true; });
window.addEventListener('pageshow', function (e) { if (e.persisted) window.__g7Unloading = false; });
// ─────────────────────────────────────────────────────────────────────────
// 자산 URL 모드 자가 복구 (이슈 #486)
//
// nginx 의 정적 최적화 블록(`location ~* \.(js|css|json)$`)은 정규식 location 이라
// 프리픽스 location 보다 먼저 매칭되고, 그 안에 PHP 핸들러가 없으면 확장자 붙은
// 동적 엔드포인트가 PHP 에 도달하지 못한 채 404 가 된다. 이 경우 관리자 화면조차
// 뜨지 않으므로 "설정을 바꾸세요" 안내는 순환 참조다 — 브라우저가 스스로 복구한다.
//
// 불변식 (계획서 §12):
// L1 전환은 단방향(extension → extensionless) 1회. 역방향 폴백을 만들지 않는다.
// 양쪽 다 실패하는 상황(PHP 다운·WAF)에서 무한 왕복이 되기 때문.
// L2 전환 시도는 기존 MAX_ATTEMPTS 예산 안에서 소비한다(별도 예산 신설 금지).
// L3 자동 location.reload() 금지. 새로고침은 폴백 UI 의 사용자 클릭만.
// L4 모든 실패 경로는 renderFallback() 으로 수렴하고 종료한다.
// L5 서버 설정은 건드리지 않는다(미인증 클라이언트의 전역 설정 변경 금지).
// L8 pending 카운터 — 실패 element 제거 후 교체, onload 는 정확히 1회만 감소.
// ─────────────────────────────────────────────────────────────────────────
// 자산 URL 모드 자가 복구 헬퍼 — `partials/asset-url-recovery` 가 <head> 에서
// 미리 정의한다(CSS <link> 의 onerror 가 그것을 먼저 쓰기 때문). 여기서는 재사용만 한다.
// 헬퍼가 없으면(부분 include 등) 자가 복구 없이 기존 재시도 로직으로만 동작한다.
var assetUrl = window.__g7AssetUrl || null;
// 부트스트랩 상태 — 정적 <script> 의 onerror/onload 가 여기에 기록한다
var bootstrap = window.__g7Bootstrap = {
failed: false,
pending: 0,
/** 모드 전환을 이미 시도했는지 (L1 — 페이지 수명당 1회) */
modeSwitched: false,
/**
* 정적 <script> 로드 실패 시 동적 재시도를 시작한다.
*
* <script> 의 onerror 는 실패 사유를 주지 않으므로(404 인지 네트워크 유실인지
* 구분 불가) 모든 실패를 재시도한다. 404 라면 3회 후 실패로 끝나므로 안전하다.
*
* @param src 재시도할 스크립트 URL
* @param attempt 현재 시도 번호 (1부터)
*/
retry: function (src, attempt) {
attempt = attempt || 1;
if (window.__g7Unloading) return;
if (attempt >= MAX_ATTEMPTS) {
console.error(LABEL + ' Failed to load after ' + MAX_ATTEMPTS + ' attempts: ' + src);
bootstrap.failed = true;
bootstrap.renderFallback();
return;
}
// 자산 URL 모드 전환 (L1·L2) — 확장자 형태가 실패했고 아직 전환 전이라면,
// 지연 재시도 대신 확장자 없는 형태로 **즉시 1회** 시도한다.
// 이 시도는 기존 예산(attempt)을 그대로 소비하므로 총 시도 횟수는 불변이다.
var converted = (bootstrap.modeSwitched || !assetUrl) ? null : assetUrl.toExtensionless(src);
if (converted && converted !== src) {
bootstrap.modeSwitched = true;
assetUrl.switchToExtensionless();
console.warn(LABEL + ' Retrying with extensionless URL: ' + converted);
bootstrap.replaceScript(converted, attempt);
return;
}
var delay = BASE_DELAY_MS * Math.pow(2, attempt - 1);
console.warn(LABEL + ' Script load failed (attempt ' + attempt + '/' + MAX_ATTEMPTS + '), retrying in ' + delay + 'ms: ' + src);
setTimeout(function () {
var script = document.createElement('script');
script.src = src;
script.async = false; // 삽입 순서대로 실행 (코어 → 컴포넌트 순서 보장)
script.onload = function () {
bootstrap.pending -= 1;
bootstrap.tryInit();
};
script.onerror = function () {
if (script.parentNode) script.parentNode.removeChild(script);
bootstrap.retry(src, attempt + 1);
};
document.head.appendChild(script);
}, delay);
},
/**
* 스크립트를 지연 없이 즉시 교체 삽입한다 (모드 전환 재시도용).
*
* L8 — onload 는 pending 을 정확히 1회만 감소시키고, 실패한 element 는
* 제거한 뒤 다음 경로로 넘어간다. 실패 시에도 attempt 를 증가시켜
* MAX_ATTEMPTS 예산을 공유하므로 총 네트워크 시도 횟수가 늘지 않는다(L2).
*
* @param src 삽입할 스크립트 URL
* @param attempt 현재 시도 번호
*/
replaceScript: function (src, attempt) {
if (window.__g7Unloading) return;
var script = document.createElement('script');
script.src = src;
script.async = false; // 삽입 순서대로 실행 (코어 → 컴포넌트 순서 보장)
script.onload = function () {
bootstrap.pending -= 1;
bootstrap.tryInit();
};
script.onerror = function () {
if (script.parentNode) script.parentNode.removeChild(script);
bootstrap.retry(src, attempt + 1);
};
document.head.appendChild(script);
},
/**
* 부트스트랩 최종 실패 시 사용자에게 보이는 정적 폴백을 심는다.
*
* 코어 번들이 없을 수 있으므로 템플릿 엔진에 의존하지 않는 순수 DOM + 인라인 스타일.
* 종전에는 콘솔에만 기록되어 사용자에게는 영구 백지로 보였다.
*/
renderFallback: function () {
if (window.__g7Unloading) return;
var app = document.getElementById('app');
if (!app) return;
if (app.childElementCount > 0) return; // 이미 렌더된 화면은 덮지 않는다
app.innerHTML =
'<div style="min-height:60vh;display:flex;align-items:center;justify-content:center;padding:24px;' +
'font-family:system-ui,-apple-system,\'Segoe UI\',sans-serif;">' +
'<div style="text-align:center;max-width:420px;">' +
'<div style="font-size:40px;line-height:1;margin-bottom:16px;">&#9888;&#65039;</div>' +
'<h1 style="font-size:18px;font-weight:600;margin:0 0 8px;color:#111827;">' +
@json(__('errors.bootstrap.title')) +
'</h1>' +
'<p style="font-size:14px;line-height:1.6;margin:0 0 20px;color:#6b7280;">' +
@json(__('errors.bootstrap.message')) +
'</p>' +
'<button type="button" onclick="window.location.reload()" ' +
'style="border:0;border-radius:6px;background:#2563eb;color:#fff;font-size:14px;font-weight:500;' +
'padding:10px 20px;cursor:pointer;">' +
@json(__('errors.bootstrap.reload')) +
'</button>' +
'</div></div>';
},
/**
* 두 번들이 모두 로드된 뒤 엔진을 초기화한다.
*
* 재시도로 늦게 도착한 경우에도 정확히 1회만 초기화되도록 pending 카운터로 게이트한다.
*/
tryInit: function () {
if (bootstrap.pending > 0 || bootstrap.failed || bootstrap.initialized) return;
bootstrap.initialized = true;
if (window.G7Core && window.G7Core.initTemplateApp) {
window.G7Core.initTemplateApp(@json($initConfig));
return;
}
// 번들은 받았으나 전역이 없다 = 번들 자체가 손상됨
console.error(LABEL + ' G7Core.initTemplateApp is not available');
bootstrap.renderFallback();
},
};
// 정적 <script> 2개(코어 + 컴포넌트)가 완료돼야 초기화한다
bootstrap.pending = 2;
})();
</script>
{{-- 코어 렌더링 엔진 — 정적 태그 유지 (프리로드 스캐너가 선행 로드) --}}
<script
src="{{ $coreEngineSrc }}"
onload="window.__g7Bootstrap.pending -= 1; window.__g7Bootstrap.tryInit();"
onerror="window.__g7Bootstrap.retry(this.src, 1);"
></script>
@include('partials.template-externals-scripts', ['position' => 'before-template'])
{{-- 템플릿 컴포넌트 번들 (IIFE) — 정적 태그 유지 --}}
<script
src="{{ $componentsSrc }}"
onload="window.__g7Bootstrap.pending -= 1; window.__g7Bootstrap.tryInit();"
onerror="window.__g7Bootstrap.retry(this.src, 1);"
></script>