Files
Gnuboard7/resources/views/partials/asset-url-recovery.blade.php
T
HeuJung 5ba7a83597 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
2026-08-25 17:02:53 +09:00

257 lines
11 KiB
PHP

{{--
자산 URL 모드 자가 복구 — 공용 헬퍼 (이슈 #486 §5)
`<head>` 의 CSS `<link>` 보다 **먼저** 로드되어야 한다. CSS 링크의 onerror 가
이 헬퍼를 호출하기 때문이며, 부트스트랩 스크립트(`<body>`)도 같은 헬퍼를 재사용한다.
코어 번들 로드 전에 실행되므로 외부 의존성 없는 순수 인라인 JS 여야 한다
(닭-달걀: 코어가 없으면 코어를 받아올 코드도 없다).
## 배경
nginx 의 정적 최적화 블록은 정규식 location 이라 프리픽스 location 보다 먼저
매칭된다. 그 안에 PHP 핸들러가 없으면 확장자 붙은 동적 엔드포인트가 PHP 에
도달하지 못한 채 404 가 된다. 관리자 화면조차 뜨지 않으므로 "설정을 바꾸세요"
안내는 순환 참조 — 브라우저가 스스로 복구해야 한다.
## 불변식 (계획서 §12)
L1 전환은 단방향(extension → extensionless) 1회. 역방향 폴백을 만들지 않는다.
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 () {
'use strict';
var MODE_EXTENSIONLESS = 'extensionless';
var FILE_QUERY_PARAM = 'file';
var STORAGE_TTL_MS = 24 * 60 * 60 * 1000;
// cache_version 은 blade 에서 직접 주입한다. 이 파샬은 <head> 에서 실행되어
// window.G7Config(<body> 에서 정의) 가 아직 없기 때문 — 참조하면 항상 0 이 되어
// 캐시 키가 어긋난다.
var CACHE_VERSION = {{ (int) ($extensionCacheVersion ?? 0) }};
function storageKey() {
return 'g7_asset_url_mode:' + CACHE_VERSION;
}
/**
* 확장자 형태 API URL 을 확장자 없는 형태로 변환한다.
*
* `resources/js/core/support/assetUrl.ts::convertToCurrentMode` 와 **동일 규칙**이어야
* 한다. 여기는 코어 번들 로드 전이라 import 가 불가능해 인라인으로 둔다 — 드리프트는
* `assetUrl.test.ts` 의 대조 케이스가 잡는다.
*
* 변환 대상이 아니면 null. 코어 엔진 번들(`/build/...`)은 public/ 의 실물 정적
* 파일이라 변환하면 오히려 깨지므로 대상에서 제외된다.
*
* @param url 원본 URL
* @returns 변환된 URL 또는 null
*/
function toExtensionless(url) {
if (!url) return null;
// `<script>.src` / `<link>.href` 는 **절대 URL** 을 돌려준다
// (`https://host/api/...`). same-origin 이면 경로만 남긴다 — 이 정규화를
// 빠뜨리면 부트스트랩 재시도 경로에서 변환이 항상 null 이 되어 자가 복구가
// 통째로 죽는다(외형은 "3회 재시도 후 폴백 UI" 라 원인이 드러나지 않는다).
var origin = window.location && window.location.origin;
if (origin && url.indexOf(origin) === 0) {
url = url.slice(origin.length);
}
if (url.indexOf('/api/') !== 0) return null;
var qi = url.indexOf('?');
var path = qi === -1 ? url : url.slice(0, qi);
var query = qi === -1 ? '' : url.slice(qi + 1);
var bundle = path.match(/^\/api\/(modules|plugins)\/bundle\.(js|css)$/);
if (bundle) {
return '/api/' + bundle[1] + '/bundle/' + bundle[2] + (query ? '?' + query : '');
}
var asset = path.match(/^\/api\/(templates|modules|plugins)\/assets\/([^/]+)\/(.+)$/);
if (asset) {
return '/api/' + asset[1] + '/assets/' + asset[2] +
'?' + FILE_QUERY_PARAM + '=' + encodeURIComponent(decodeURIComponent(asset[3])) +
(query ? '&' + query : '');
}
var suffixed = path.match(/^(\/api\/.+)\.(json|js|css)$/);
if (suffixed) {
return suffixed[1] + (query ? '?' + query : '');
}
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회).
*
* 서버 설정은 바꾸지 않는다(L5). 전역 플래그와 localStorage 캐시만 갱신하며,
* 이후 엔진의 모든 fetch 가 이 값을 읽어 올바른 형태를 쓴다.
*
* @returns 이번 호출로 전환되었으면 true
*/
function switchToExtensionless() {
if (window.__g7AssetUrlMode === MODE_EXTENSIONLESS) return false;
// 독립 전역에 기록한다. <body> 의 `window.G7Config = {...}` 대입이 이 파샬보다
// 나중에 실행되어 객체를 통째로 교체하므로, G7Config 에만 쓰면 덮여 사라진다.
// G7Config 는 이 값을 초기값으로 읽어간다(app/admin blade 참조).
window.__g7AssetUrlMode = MODE_EXTENSIONLESS;
if (window.G7Config) window.G7Config.assetUrlMode = MODE_EXTENSIONLESS;
try {
window.localStorage.setItem(storageKey(), JSON.stringify({
mode: MODE_EXTENSIONLESS,
at: Date.now()
}));
} catch (e) { /* 저장 실패는 치명적이지 않다 */ }
return true;
}
/**
* 이전 방문에서 확정된 모드를 복원한다 (L7).
*
* cache_version 을 키에 포함하고 TTL 을 둬서 서버가 정상화된 뒤에도
* 클라이언트가 옛 모드에 영구 고착되지 않도록 한다.
*/
function restoreCachedMode() {
try {
var raw = window.localStorage.getItem(storageKey());
if (!raw) return false;
var parsed = JSON.parse(raw);
if (!parsed || parsed.mode !== MODE_EXTENSIONLESS) return false;
if (typeof parsed.at !== 'number' || Date.now() - parsed.at > STORAGE_TTL_MS) {
window.localStorage.removeItem(storageKey());
return false;
}
window.__g7AssetUrlMode = MODE_EXTENSIONLESS;
if (window.G7Config) window.G7Config.assetUrlMode = MODE_EXTENSIONLESS;
return true;
} catch (e) {
return false;
}
}
/**
* CSS `<link>` 로드 실패 시 확장자 없는 형태로 1회 교체한다.
*
* 스타일 부재는 앱을 죽이지 않으므로(엔진의 CSS 로더도 실패를 resolve 로 처리)
* 재교체가 실패해도 조용히 끝낸다 — 폴백 UI 를 띄우지 않는다(과잉 적용 경계).
* 교체는 링크당 1회만 (`data-g7-recovered` 마킹).
*
* @param link 실패한 <link> 요소
*/
function recoverStylesheet(link) {
if (!link || link.getAttribute('data-g7-recovered') === '1') return;
link.setAttribute('data-g7-recovered', '1');
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();
link.href = converted;
}
restoreCachedMode();
window.__g7AssetUrl = {
MODE_EXTENSIONLESS: MODE_EXTENSIONLESS,
toExtensionless: toExtensionless,
staticToLegacy: staticToLegacy,
switchToExtensionless: switchToExtensionless,
restoreCachedMode: restoreCachedMode,
recoverStylesheet: recoverStylesheet
};
})();
</script>