공개 제보 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
306 lines
16 KiB
PHP
306 lines
16 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;
|
|
|
|
// URL 을 문서 기준 절대 URL 로 정규화한다 (ES5 — `new URL` 은 구형 브라우저에 없다).
|
|
function absoluteUrl(src) {
|
|
var a = document.createElement('a');
|
|
a.href = src;
|
|
return a.href;
|
|
}
|
|
|
|
// 부트스트랩 상태 — 정적 <script> 의 onerror/onload 가 여기에 기록한다
|
|
var bootstrap = window.__g7Bootstrap = {
|
|
failed: false,
|
|
pending: 0,
|
|
|
|
/**
|
|
* 번들이 파싱 단계에서 거부됐는지 (SyntaxError 관측 결과).
|
|
*
|
|
* 다운로드는 성공했는데 전역이 없는 상황은 두 가지다 — ① 브라우저가 번들 문법을
|
|
* 모른다(지원 범위 밖) ② 번들 자체가 손상됐다. 둘은 안내 문구가 달라야 하므로
|
|
* 실제 오류를 관측해 가른다.
|
|
*/
|
|
syntaxError: false,
|
|
|
|
/** 부팅에 필요한 번들 URL (절대 URL). 재시도로 바뀐 URL 도 여기에 누적된다. */
|
|
bundleSrcs: [absoluteUrl(@json($coreEngineSrc)), absoluteUrl(@json($componentsSrc))],
|
|
|
|
/** 재시도·모드전환으로 새 URL 을 쓰게 되면 관측 대상에 추가한다. */
|
|
trackBundleSrc: function (src) {
|
|
var abs = absoluteUrl(src);
|
|
if (bootstrap.bundleSrcs.indexOf(abs) === -1) bootstrap.bundleSrcs.push(abs);
|
|
},
|
|
|
|
/** 모드 전환을 이미 시도했는지 (L1 — 페이지 수명당 1회) */
|
|
modeSwitched: false,
|
|
|
|
/** 정적 게시 URL → API URL 전환을 이미 시도했는지 (#122 F15 — 페이지 수명당 1회) */
|
|
staticRecovered: 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('network');
|
|
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)을 그대로 소비하므로 총 시도 횟수는 불변이다.
|
|
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);
|
|
|
|
bootstrap.trackBundleSrc(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;
|
|
|
|
bootstrap.trackBundleSrc(src);
|
|
|
|
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 + 인라인 스타일.
|
|
* 종전에는 콘솔에만 기록되어 사용자에게는 영구 백지로 보였다.
|
|
*
|
|
* 사유별로 문구가 갈린다 — 새로고침이 도움이 되는 실패와 그렇지 않은 실패를
|
|
* 같은 문구로 안내하면 사용자가 자기 회선을 탓하며 영원히 새로고침하게 된다.
|
|
*
|
|
* @param reason 'network'(다운로드 실패) | 'incompatible'(브라우저가 실행 못 함)
|
|
* | 'corrupt'(받았으나 전역 부재, 사유 미상). 미지정 시 'network'.
|
|
*/
|
|
renderFallback: function (reason) {
|
|
if (window.__g7Unloading) return;
|
|
|
|
var app = document.getElementById('app');
|
|
if (!app) return;
|
|
if (app.childElementCount > 0) return; // 이미 렌더된 화면은 덮지 않는다
|
|
|
|
var incompatible = reason === 'incompatible';
|
|
|
|
var title = incompatible
|
|
? @json(__('errors.bootstrap.incompatible_title'))
|
|
: @json(__('errors.bootstrap.title'));
|
|
var message = incompatible
|
|
? @json(__('errors.bootstrap.incompatible_message'))
|
|
: @json(__('errors.bootstrap.message'));
|
|
|
|
// 새로고침해도 낫지 않는 상황에서 버튼을 두면 그것 자체가 다시 거짓 안내가 된다.
|
|
var button = incompatible
|
|
? ''
|
|
: '<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>';
|
|
|
|
app.innerHTML =
|
|
'<div data-g7-bootstrap-fallback="' + (incompatible ? 'incompatible' : 'network') + '" ' +
|
|
'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;">⚠️</div>' +
|
|
'<h1 style="font-size:18px;font-weight:600;margin:0 0 8px;color:#111827;">' +
|
|
title +
|
|
'</h1>' +
|
|
'<p style="font-size:14px;line-height:1.6;margin:0 0 20px;color:#6b7280;">' +
|
|
message +
|
|
'</p>' +
|
|
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;
|
|
}
|
|
|
|
// 번들은 받았으나 전역이 없다 = 실행되지 않았다.
|
|
// SyntaxError 를 관측했다면 브라우저가 문법을 모르는 것(지원 범위 밖)이고,
|
|
// 그렇지 않으면 사유 미상(번들 손상 등)이므로 기존 문구를 유지한다.
|
|
console.error(LABEL + ' G7Core.initTemplateApp is not available'
|
|
+ (bootstrap.syntaxError ? ' (bundle rejected at parse time)' : ''));
|
|
bootstrap.renderFallback(bootstrap.syntaxError ? 'incompatible' : 'corrupt');
|
|
},
|
|
};
|
|
|
|
// 번들의 파싱 실패 관측 (ES5) — 파싱 오류는 <script> 의 error 가 아니라 load 를
|
|
// 발생시키므로 element 이벤트로는 구분할 수 없다. window 의 error 이벤트로만 보인다.
|
|
window.addEventListener('error', function (e) {
|
|
if (!e || !e.filename) return;
|
|
if (bootstrap.bundleSrcs.indexOf(absoluteUrl(e.filename)) === -1) return;
|
|
|
|
var message = e.message ? String(e.message) : '';
|
|
if (message.indexOf('SyntaxError') !== -1 || (e.error && e.error.name === 'SyntaxError')) {
|
|
bootstrap.syntaxError = true;
|
|
}
|
|
}, true);
|
|
|
|
// 정적 <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>
|