From 6df125d84088707ef1dc6688201885f9857d2fca Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Mon, 7 Sep 2026 14:06:09 +0900 Subject: [PATCH] =?UTF-8?q?[fix]=20solution/site:=20=EB=B0=9C=ED=96=89?= =?UTF-8?q?=EB=B3=B8=EC=9D=B4=20=EC=B0=B8=EC=A1=B0=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EC=9E=90=EC=82=B0=EC=9D=80=20=EA=B8=B0=EA=B0=84=EA=B3=BC=20?= =?UTF-8?q?=EB=AC=B4=EA=B4=80=ED=95=98=EA=B2=8C=20=EB=82=A8=EA=B8=B4?= =?UTF-8?q?=EB=8B=A4=20=E2=80=94=20=EB=AA=A9=EC=97=85=EC=9D=B4=20=EC=A3=BD?= =?UTF-8?q?=EC=97=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /s/stay · /s/stay2 · /s/stay3 의 CSS·JS·이미지가 전부 404 가 됐고 재굽기로 살아나지 않았다. out/s/ 에 디렉토리가 8개인데 payload 는 4개뿐이다. 나머지는 손으로 넣은 목업이고, 프리렌더는 payload 를 받은 사이트만 굽는다 — 목업은 재굽기 대상이 아니라서 자산이 한 번 지워지면 영영 복구되지 않는다. 문서 어디에도 목업 얘기가 없어서(grep 0건) 그 존재를 모르고 자산 삭제 코드를 건드렸다. 보관 기간으로는 못 막는다. 기간이 지나면 같은 사고가 난다. 참조가 살아 있으면 남겨야 한다. - referencedAssets(): 굽기 전에 out/s/**/index.html 을 훑어 /assets/… 참조를 모은다 - pruneAssets: 그 목록은 절대 지우지 않는다 — 보관 기간보다 우선한다 - AGENTS.md: 목업의 존재와 "자산 삭제 코드는 참조를 먼저 뺀다" 를 함정 맨 위에 ★★로 - docs/DEVLOG.md: 사고 기록과 복구 경로 검증: payload 없는 목업을 재현해 재굽기 → 참조 3개 유지. 대장을 60일 전으로 돌려 만료를 강제해도 유지. tsc·eslint 통과 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018xTWrJ6Mrr6HhEN6hZEER4 --- AGENTS.md | 9 +++++ docs/DEVLOG.md | 26 ++++++++++++++ solution/site/scripts/prerender.ts | 57 +++++++++++++++++++++++++----- 3 files changed, 84 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 88bda4f..0c725e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,15 @@ 밟으면 **조용히 틀린다** — 빌드는 성공하고 화면도 뜨는데 결과가 잘못된 종류다. +- **★★ `out/s/` 에는 payload 가 없는 사이트가 있다 — 목업(`stay` · `stay2` · `stay3` · `*.old`).** + 프리렌더는 **payload 를 받은 사이트만** 굽는다. 목업은 손으로 넣은 것이라 재굽기 대상이 + 아니고, **자산이 한 번 지워지면 영영 복구되지 않는다** — 재굽기를 몇 번 돌려도 안 살아나고 + 사람이 파일을 되돌려 넣어야 한다. 실측(2026-09-07): 번들 해시가 바뀌자 목업 3개의 CSS·JS· + 이미지가 전부 404 가 됐고, 그 파일들은 `stay-mockup` 워크트리에서 손으로 꺼내 복구했다. + → **`out/assets` 에서 파일을 지우는 코드는 `out/s/**` 의 HTML 이 참조하는 것을 먼저 뺀다** + (`prerender.ts` `referencedAssets`). 보관 기간으로는 못 막는다 — 기간이 지나면 같은 일이 난다. + → 목업을 다루는 작업은 `out/s/` 를 먼저 열어 **payload 가 없는 디렉토리가 무엇인지** 본다. + - **번들 파일명은 콘텐츠 해시다.** HTML 은 `/assets/index-DvNTmLhy.css` 를 **루트 절대경로**로 가리킨다. 경로는 프리렌더가 `dist/client/.vite/manifest.json` 에서 읽어 박는다 (`prerender.ts:160`). 렌더러 CSS 를 고치면 이름이 바뀐다. diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 2c42df4..bdfbe3b 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,6 +5,32 @@ --- +## 2026-09-07 — (사고 2) 목업 사이트가 죽었다 — 참조된 자산은 기간과 무관하게 남긴다 + +**무슨 일** +`/s/stay` · `/s/stay2` · `/s/stay3` 의 CSS·JS·이미지가 전부 404 가 됐다. 재굽기를 돌려도 +살아나지 않았다. + +**왜** +`out/s/` 에 디렉토리가 8개인데 payload 는 4개뿐이다. 나머지는 **손으로 넣은 목업**이고, +프리렌더는 payload 를 받은 사이트만 굽는다 — 목업은 **재굽기 대상이 아니다.** 그래서 번들 +해시가 바뀌어 옛 자산이 지워지는 순간 영영 복구 불가가 된다. 문서 어디에도 목업 얘기가 +한 줄도 없어서(2026-09-07 grep 0건) 이 존재를 모르고 자산 삭제 코드를 건드렸다. + +**고친 것** (`scripts/prerender.ts`) +- `referencedAssets()` — 굽기 **전에** `out/s/**/index.html` 을 훑어 `/assets/…` 참조를 모은다 +- `pruneAssets` 가 그 목록을 절대 지우지 않는다. **보관 기간보다 우선한다** — + 기간으로 막으면 30일 뒤에 똑같은 사고가 난다 +- AGENTS.md 함정 목록 맨 위에 ★★ 로 박았다. 목업의 존재 자체가 문서에 없던 게 근본 원인이다 + +**복구** — 지워진 파일은 `stay-mockup` 워크트리(`solution/site/out/assets`)에 남아 있어서 +서버 볼륨에 손으로 되돌려 넣었다. `docker cp` → `out/assets`. + +**검증** — 목업 상황 재현: payload 없는 `out/s/mock/index.html` 이 옛 해시를 가리키게 두고 +재굽기 → 참조 3개가 남는다. 대장을 60일 전으로 돌려 만료를 강제해도 그대로 남는다. + +--- + ## 2026-09-07 — (사고) 자산 보관 첫 배포에 운영 사이트 CSS 가 끊겼다 **무슨 일** diff --git a/solution/site/scripts/prerender.ts b/solution/site/scripts/prerender.ts index 495dc79..4cd7181 100644 --- a/solution/site/scripts/prerender.ts +++ b/solution/site/scripts/prerender.ts @@ -285,7 +285,12 @@ function assetPlan(payload: SitePayload, outRoot: string, siteDir: string) { }; } -function prerenderSite(input: SitePayload, outRoot: string, assets: ReturnType) { +function prerenderSite( + input: SitePayload, + outRoot: string, + assets: ReturnType, + referenced: Set, +) { // ★ 여기서 한 번 깎고, 그 뒤로는 깎인 payload 만 쓴다 — // 서버 렌더 · JSON-LD · llms.txt · HTML 에 심는 블롭이 전부 같은 객체를 본다. // (블롭만 원본으로 두면 미검증 fact 가 HTML 소스로 새고, AI 크롤러는 그걸 읽는다.) @@ -370,7 +375,7 @@ function prerenderSite(input: SitePayload, outRoot: string, assets: ReturnType { + const sitesDir = join(outRoot, SITE_DIR); + const found = new Set(); + if (!existsSync(sitesDir)) return found; + + for (const entry of readdirSync(sitesDir, {withFileTypes: true})) { + if (!entry.isDirectory()) continue; + const file = join(sitesDir, entry.name, 'index.html'); + if (!existsSync(file)) continue; + // basePath 가 붙어도(`/sites/assets/…`) `/assets/` 뒤만 집으면 파일 경로가 나온다. + for (const match of readFileSync(file, 'utf-8').matchAll(/\/assets\/([^"'\s)\\]+)/g)) { + found.add(decodeURIComponent(match[1])); + } + } + return found; +} + function readAssetLedger(assetsDir: string): AssetBuild[] { try { const parsed = JSON.parse(readFileSync(join(assetsDir, ASSET_LEDGER), 'utf-8')) as { @@ -543,7 +579,7 @@ function readAssetLedger(assetsDir: string): AssetBuild[] { * ★ 발행할 때마다 이 함수가 돈다(사이트 하나만 구울 때도). 번들이 그대로면 대장에 줄이 * 늘지 않고 맨 앞 줄의 시각만 갱신된다 — 안 그러면 발행 횟수만큼 대장이 자란다. */ -function pruneAssets(assetsDir: string, current: string[]) { +function pruneAssets(assetsDir: string, current: string[], referenced: Set) { const signature = (files: string[]) => [...files].sort().join('\n'); const now = new Date().toISOString(); const previous = readAssetLedger(assetsDir); @@ -579,7 +615,8 @@ function pruneAssets(assetsDir: string, current: string[]) { let removed = 0; for (const file of listRelativeFiles(assetsDir)) { - if (file === ASSET_LEDGER || alive.has(file)) continue; + // ★ 참조가 살아 있으면 기간과 무관하게 남긴다 — referencedAssets 주석 참조. + if (file === ASSET_LEDGER || alive.has(file) || referenced.has(file)) continue; rmSync(join(assetsDir, file), {force: true}); removed += 1; } @@ -601,12 +638,12 @@ function pruneAssets(assetsDir: string, current: string[]) { * 남겨야 한다 — 근거는 ASSET_RETENTION_DAYS 주석. 권한 때문에 통째로 지웠던 것인데, * copyDirectoryFiles 가 **파일마다** 먼저 rmSync 하므로 그 문제는 그대로 해결된다. */ -function writeSharedAssets(destRoot: string) { +function writeSharedAssets(destRoot: string, referenced: Set) { const assetsSrc = join(CLIENT_DIR, 'assets'); if (existsSync(assetsSrc)) { const assetsDest = join(destRoot, 'assets'); copyDirectoryFiles(assetsSrc, assetsDest); - pruneAssets(assetsDest, listRelativeFiles(assetsSrc)); + pruneAssets(assetsDest, listRelativeFiles(assetsSrc), referenced); } const publicDir = join(SITE_ROOT, 'public'); if (existsSync(publicDir)) { @@ -698,8 +735,12 @@ function main() { console.log(`[prerender] 사이트 ${loaded.length}개 → ${args.out}`); + // ★ 굽기 **전에** 참조를 훑는다. 이번에 다시 굽지 않는 사이트(payload 가 없는 목업 포함)가 + // 무엇을 가리키고 있는지는 지금 디스크에 있는 HTML 만 안다. + const referenced = referencedAssets(args.out); + // ★ 공용 자산은 사이트를 굽기 전에 딱 한 번 깐다. 사이트마다 복사하던 걸 여기로 뺐다. - writeSharedAssets(args.out); + writeSharedAssets(args.out, referenced); let failed = 0; /** 루트 기계용 파일을 쓸 오리진. 이 호스트의 사이트는 전부 같은 오리진을 쓴다. */ @@ -743,7 +784,7 @@ function main() { } try { - const result = prerenderSite(entry.payload, args.out, assets); + const result = prerenderSite(entry.payload, args.out, assets, referenced); const payload = result.payload; origin = origin || payload.site.origin; const home = joinUrl(payload.site.origin, payload.site.basePath);