세 가지가 한 줄기다 — 목업에만 있던 것을 제품으로 옮기면서, 그게 이미 나가 있는
사이트를 건드리지 않게 하는 데까지가 한 변경이다.
① 지역 읽기(mockup/README T7) — 목업은 주입 스크립트로 그렸고 렌더러엔 없었다.
새로 발행한 업장에서는 영영 빈자리였다(`daily` 가 프롬프트를 빌더에만 둬서 서버가
그 종류를 몰랐던 것과 같은 사고).
· shared: `ReadingItem` · `SECTION_ITEM_REQUIRED_KEY.reading` · `reading` 프롬프트
(프롬프트 단일 출처는 `section-prompts.ts` 하나다 — 코드에 문장을 박지 않는다)
· backend: STORY_KINDS 등록. `_SEARCH_LINK_KINDS` — 이 종류는 모델의 URL 을 안 받고
제목으로 만든 네이버 검색 링크를 코드가 붙인다(주소를 짐작해 적으면 없는 문서로 간다)
· site: '지역 이야기' 여섯 번째 탭. 구운 HTML 은 앞에서 여섯 꼭지, 붙은 뒤 한 번 섞는다
· 탭 이름은 `{지명} 읽기` — '군산' 을 코드에 박지 않는다
② 엽서 공유가 모든 발행 사이트에서 막혀 있던 것. 사진이 `*.pstatic.net` ·
`tong.visitkorea.or.kr` 에 있고 그쪽이 `Access-Control-Allow-Origin` 을 안 준다
(실측 세 곳 모두 없음) — 캔버스가 오염돼 `toBlob` 이 죽는다. 클라이언트에서는 못 넘는다.
· `prerender.ts mirrorMedia`: 굽기 전에 `s/<slug>/img/<주소해시>.<확장자>` 로 받고
payload 주소를 우리 오리진 절대주소로 바꾼다(og:image·JSON-LD 도 같은 값을 쓴다)
· 못 받으면 원래 주소를 쓴다. 파일명이 주소 해시라 다시 구워도 안 받는다
· `originUrl`·`sourceType` 은 그대로 — DECISIONS 1-2 가 "불가" 면 CRAWL 제외가 먹어야 한다
· 엽서 미리보기를 240px 로 묶었다(대표: "엽서 ui 너무 큼")
③ **기동이 전부 다시 굽지 않는다** (대표: "전체 재굽기 할 필요가 없어, 사장님이
재발행하면 끝인데 / css js만 안 깨지게 하란 말이야").
렌더러를 한 줄 고칠 때마다 이미 나가 있는 사이트의 HTML 이 통째로 바뀌던 자리다.
· `watch-payloads.mjs`: 기동 = `--refresh-assets` 하나. 한 번도 안 구워진 payload 만 굽는다
· `prerender.ts refreshBakedAssets`: 구워진 HTML 의 `assets/index-<해시>.css|js` 파일명만
새 번들로 바꾼다. 내용·payload·접두사는 그대로. 보호 슬러그는 건너뛴다
· 그래서 ①②는 **다음 발행 때** 그 사이트에 들어간다
문서: AGENTS.md 함정 둘(사진 내려받기 · 기동은 안 굽는다) 추가, 전체 재굽기를 전제하던
옛 항목 둘을 고쳤다. mockup/README T7 은 "제품에 들어갔다" 로, DATA_MODEL 의 STORY kind 목록 갱신.
검증: tsc·eslint 통과(site·frontend), site 79 passed(읽기 4건 추가).
실측 — buru 굽기: 사진 10장 내려받고 og:image 가 우리 주소, 재굽기 때 0건;
`--refresh-assets`: 옛 해시로 바꿔 둔 index.html 1곳이 새 번들 주소로 바뀌고 내용은 그대로.
백엔드 테스트는 이 기계의 5432 가 다른 터널에 물려 있어 못 돌렸다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
295 lines
14 KiB
JavaScript
295 lines
14 KiB
JavaScript
/**
|
|
* payload 감시 → 자동 프리렌더.
|
|
*
|
|
* ★ 왜 필요한가
|
|
* 발행 잡은 payload JSON 까지만 만든다(backend/services/site_payload). 그 뒤 HTML 로 굽는
|
|
* 단계가 수동이라, 사장님이 [발행]을 눌러도 사이트가 없었다 — [사이트 열기] 가 404 였다.
|
|
* 검색 → 크롤링 → 발행 → **사이트 이동** 이 끊기는 유일한 자리가 여기다.
|
|
*
|
|
* ★ 왜 백엔드 워커가 직접 안 굽나
|
|
* 굽는 데 Node 와 이 프로젝트의 의존성이 필요하다. 파이썬 컨테이너에 Node 를 넣으면
|
|
* 백엔드 이미지가 프론트 빌드 도구를 떠안는다. 대신 payload 디렉토리를 사이에 두고
|
|
* 따로 도는 프로세스가 읽는다 — 백엔드는 파일만 쓰고, 여기는 파일만 본다.
|
|
*
|
|
* ★ 바뀐 사이트만 굽는다
|
|
* 예전에는 payload 디렉토리를 통째로 넘겨서, 한 명이 발행하면 발행된 사이트 전부를
|
|
* 다시 구웠다(게다가 매번 vite 클라이언트 번들까지 새로 만들었다). 사이트가 늘면
|
|
* 그대로 못 쓴다. 지금은 mtime 이 바뀐 payload 만 골라 넘기고, 번들은 기동 때 한 번만 만든다.
|
|
*
|
|
* ★ 실패를 삼키지 않는다
|
|
* 프리렌더가 깨지면 DB 에는 "발행됨"인데 페이지는 없는 상태가 된다. 실패한 payload 는
|
|
* 백오프를 두고 다시 시도하고, 소진되면 경고로 남긴다. 결과 보고서는 프리렌더가
|
|
* payloads/.status/<slug>.json 에 쓴다(백엔드가 그걸 읽는다).
|
|
*
|
|
* 실행: npm run watch (개발)
|
|
* node scripts/watch-payloads.mjs --once (한 번만)
|
|
*/
|
|
import {spawn} from 'node:child_process';
|
|
import {existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync} from 'node:fs';
|
|
import {dirname, join, resolve} from 'node:path';
|
|
import {fileURLToPath} from 'node:url';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = resolve(HERE, '..');
|
|
const PAYLOAD_DIR = join(ROOT, 'payloads');
|
|
const PRERENDER_JS = join(ROOT, 'dist', 'prerender', 'prerender.js');
|
|
const SITES_DIR = join(ROOT, 'out', 's');
|
|
const ONCE = process.argv.includes('--once');
|
|
|
|
/** 폴링 간격. 발행은 분 단위 작업이라 2초 지연은 문제가 되지 않는다. */
|
|
const POLL_MS = 2000;
|
|
/** 실패한 payload 재시도 횟수. 소진되면 경고만 남기고 다음 변경을 기다린다. */
|
|
const MAX_ATTEMPTS = 3;
|
|
/** 재시도 백오프(회차별 ms). 디스크 순단·부분 기록 같은 일시 실패를 흡수한다. */
|
|
const BACKOFF_MS = [5000, 20000];
|
|
|
|
function log(msg) {
|
|
console.log(`[watch ${new Date().toTimeString().slice(0, 8)}] ${msg}`);
|
|
}
|
|
|
|
function warn(msg) {
|
|
console.error(`[watch ${new Date().toTimeString().slice(0, 8)}] ${msg}`);
|
|
}
|
|
|
|
/**
|
|
* 자식 프로세스 하나를 돌리고 `{code, stderr}` 를 돌려준다.
|
|
*
|
|
* ★ stderr 를 흘려보내면서 동시에 모은다. 화면에는 지금까지처럼 그대로 나가야 하고
|
|
* (개발자가 보는 것), 실패 보고서에는 사유가 실려야 한다(백엔드가 읽는 것).
|
|
*/
|
|
function run(command, args, label) {
|
|
return new Promise((done) => {
|
|
log(`${label} 시작`);
|
|
const child = spawn(command, args, {cwd: ROOT, stdio: ['inherit', 'inherit', 'pipe'], shell: false});
|
|
let stderr = '';
|
|
child.stderr.on('data', (chunk) => {
|
|
process.stderr.write(chunk);
|
|
// 사유는 앞부분에 나온다. 스택 전체를 들고 있을 이유가 없다.
|
|
if (stderr.length < 4000) stderr += chunk.toString();
|
|
});
|
|
child.on('close', (code) => {
|
|
if (code === 0) log(`${label} 완료`);
|
|
else warn(`${label} 실패(exit ${code})`);
|
|
done({code: code ?? 1, stderr});
|
|
});
|
|
child.on('error', (ex) => {
|
|
warn(`${label} 실행 불가: ${ex.message}`);
|
|
done({code: 1, stderr: ex.message});
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 프리렌더가 **자기 보고서를 쓰지도 못하고 죽었을 때** 대신 실패를 남긴다.
|
|
*
|
|
* ★ 왜 필요한가
|
|
* 프리렌더는 사이트별 실패를 스스로 `.status/<slug>.json` 에 적는다. 그런데 프로세스가
|
|
* 사이트를 하나도 돌기 전에 죽으면(의존성 누락·번들 오류) 보고서가 아예 안 생긴다.
|
|
* 그러면 백엔드 BUILD 잡은 180초를 꼬박 기다린 뒤 "결과를 못 받았다"로만 실패한다 —
|
|
* 진짜 사유(예: Cannot find package 'embla-carousel-react')는 컨테이너 로그에만 남고
|
|
* 사장님 화면에도, 발행 기록에도 안 나타난다. 실제로 그 상태로 47분간 모든 사이트의
|
|
* 재발행이 조용히 죽어 있었다.
|
|
*
|
|
* ★ 재시도가 소진된 뒤에만 쓴다. 첫 실패에 바로 쓰면 백엔드가 그 보고서를 집어가서
|
|
* 재시도가 성공해도 이미 늦는다 — 백오프(5s+20s)는 180초 안에 끝나므로 여유가 있다.
|
|
*
|
|
* ★ siteVersion 을 payload 에서 읽어 싣는다. 백엔드는 **버전이 맞는 보고서만** 받으므로
|
|
* (backend/services/render_report.wait_for) 버전이 없으면 이 보고서는 무시된다.
|
|
*/
|
|
function writeFailureReport(file, error) {
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(readFileSync(file, 'utf-8'));
|
|
} catch (ex) {
|
|
warn(`실패 보고서를 쓰지 못했다(payload 를 읽을 수 없다) ${basenameOf(file)}: ${ex.message}`);
|
|
return;
|
|
}
|
|
const site = payload?.site ?? {};
|
|
const slug = site.slug || basenameOf(file).replace(/\.json$/, '');
|
|
const report = {
|
|
schemaVersion: 1,
|
|
slug,
|
|
siteId: site.siteId ?? '',
|
|
placeId: site.placeId ?? '',
|
|
siteVersion: site.version ?? 0,
|
|
ok: false,
|
|
renderedAt: new Date().toISOString(),
|
|
error: (error || '프리렌더가 보고서를 남기지 못하고 종료했다').slice(0, 1000),
|
|
};
|
|
try {
|
|
const dir = join(PAYLOAD_DIR, '.status');
|
|
mkdirSync(dir, {recursive: true});
|
|
const tmp = join(dir, `.${slug}.json.tmp`);
|
|
writeFileSync(tmp, JSON.stringify(report, null, 2), 'utf-8');
|
|
renameSync(tmp, join(dir, `${slug}.json`));
|
|
warn(`실패 보고서 기록 — .status/${slug}.json (백엔드가 이 사유로 발행을 중단한다)`);
|
|
} catch (ex) {
|
|
warn(`실패 보고서를 쓰지 못했다 ${slug}: ${ex.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 클라이언트 번들 + 프리렌더 번들을 만든다. **기동 때 한 번만** 부른다.
|
|
*
|
|
* ★ payload 가 바뀌었다고 번들을 다시 만들 이유가 없다. 데이터만 바뀌었고 코드는 그대로다.
|
|
* 코드가 바뀌면 컨테이너가 다시 뜨고, 그때 여기를 지난다.
|
|
*/
|
|
/**
|
|
* 클라이언트 번들 → 프리렌더 번들. 앞이 실패하면 뒤는 돌리지 않는다.
|
|
*
|
|
* ★ run() 은 `{code, stderr}` 를 돌려준다. 예전에는 이 값을 숫자로 알고 `code === 0` 으로
|
|
* 비교했는데, 객체는 0 과 절대 같지 않아서 **프리렌더 번들이 한 번도 실행되지 않았다.**
|
|
* 기동 때마다 "번들 빌드 실패" 로 끝났고, 그래서 DEPLOY.md 가 약속한 '기동 시 전체
|
|
* 재굽기'가 실제로는 일어나지 않았다.
|
|
*/
|
|
async function buildBundles() {
|
|
const client = await run('npm', ['run', 'build:client'], '클라이언트 번들');
|
|
if (client.code !== 0) return client;
|
|
return run('npm', ['run', 'build:prerender'], '프리렌더 번들');
|
|
}
|
|
|
|
/** payload 파일들을 굽는다. 빈 배열이면 아무것도 하지 않는다.
|
|
* ★ 호출부가 `{code, stderr}` 를 구조분해하므로 빈 경우에도 같은 모양을 돌려준다 —
|
|
* 숫자 0 을 돌려주면 code 가 undefined 가 되어 성공이 실패로 읽힌다. */
|
|
function prerender(files, reason) {
|
|
if (files.length === 0) return Promise.resolve({code: 0, stderr: ''});
|
|
const label = `프리렌더 ${files.length}개 — ${reason}`;
|
|
return run('node', [PRERENDER_JS, ...files.map((file) => `--payload=${file}`)], label);
|
|
}
|
|
|
|
/**
|
|
* 기동 때 하는 일 — **굽지 않고 자산 주소만 갈아 끼운다.**
|
|
*
|
|
* ★ 왜 (2026-09-15 대표 지시: "전체 재굽기 할 필요가 없어, 사장님이 재발행하면 끝인데 /
|
|
* css js만 안 깨지게 하란 말이야")
|
|
* 예전에는 여기서 payload 를 전부 다시 구웠다. 그러면 렌더러를 고칠 때마다 이미 나가 있는
|
|
* 사이트의 HTML 이 통째로 바뀐다 — 사장님은 발행한 적이 없는데 내용이 달라진다.
|
|
* 새 기능은 **다음 발행 때** 들어가면 되고, 기존 사이트는 번들만 안 깨지면 된다.
|
|
* ★ 번들 파일명이 콘텐츠 해시라 이것마저 안 하면 옛 HTML 이 옛 번들을 가리킨 채 굳는다.
|
|
* 자산 자체는 지워지지 않지만(`prerender.ts` referencedAssets) 디자인 수정이 영영 안 간다.
|
|
*/
|
|
function refreshAssets() {
|
|
// payload 디렉토리를 넘긴다 — 자산 주소를 갈아 끼울 대상을 **payload 가 있는 슬러그로**
|
|
// 좁히려는 것이다. 목업(payload 없는 디렉토리)은 손으로 바꾼다(AGENTS.md 함정 1).
|
|
return run(
|
|
'node',
|
|
[PRERENDER_JS, '--refresh-assets', `--payload-dir=${PAYLOAD_DIR}`],
|
|
'자산 주소 갱신 — 기동',
|
|
);
|
|
}
|
|
|
|
// ── 큐 ────────────────────────────────────────────────────────────────────
|
|
/** 굽기를 기다리는 payload 경로. 굽는 동안 들어온 변경은 여기에 쌓였다가 이어서 돈다. */
|
|
const pending = new Set();
|
|
/** payload 경로 → 지금까지 실패한 횟수. 성공하면 지운다. */
|
|
const attempts = new Map();
|
|
let running = false;
|
|
|
|
function enqueue(file) {
|
|
pending.add(file);
|
|
void drain();
|
|
}
|
|
|
|
async function drain() {
|
|
if (running || pending.size === 0) return;
|
|
running = true;
|
|
try {
|
|
while (pending.size > 0) {
|
|
const batch = [...pending];
|
|
pending.clear();
|
|
|
|
const {code, stderr} = await prerender(batch, batch.map((f) => basenameOf(f)).join(', '));
|
|
if (code === 0) {
|
|
batch.forEach((file) => attempts.delete(file));
|
|
continue;
|
|
}
|
|
|
|
// ★ 배치가 실패하면 어느 사이트가 깨졌는지는 보고서(.status/<slug>.json)에 남는다.
|
|
// 여기서는 배치 전체를 재시도한다 — 프리렌더는 사이트별로 실패를 격리하므로
|
|
// 이미 성공한 사이트를 다시 구워도 결과는 같다(멱등).
|
|
for (const file of batch) {
|
|
const tried = (attempts.get(file) ?? 0) + 1;
|
|
attempts.set(file, tried);
|
|
if (tried >= MAX_ATTEMPTS) {
|
|
warn(`${basenameOf(file)} — ${tried}회 실패, 재시도를 멈춥니다.`);
|
|
// 프리렌더가 자기 보고서를 못 남기고 죽었을 수 있다 — 그러면 백엔드가 180초를
|
|
// 헛기다린 뒤 사유 없이 실패한다. 여기서 사유를 실어 남긴다(writeFailureReport 주석 참조).
|
|
writeFailureReport(file, stderr.trim());
|
|
continue;
|
|
}
|
|
const delay = BACKOFF_MS[Math.min(tried - 1, BACKOFF_MS.length - 1)];
|
|
warn(`${basenameOf(file)} — 실패(${tried}/${MAX_ATTEMPTS}), ${delay / 1000}초 후 재시도`);
|
|
setTimeout(() => enqueue(file), delay);
|
|
}
|
|
}
|
|
} finally {
|
|
running = false;
|
|
}
|
|
}
|
|
|
|
function basenameOf(file) {
|
|
return file.slice(file.lastIndexOf('/') + 1);
|
|
}
|
|
|
|
/** 그 payload 가 이미 구워져 있나. 파일명이 슬러그다(백엔드가 `<slug>.json` 으로 쓴다). */
|
|
function bakedIndexOf(file) {
|
|
return join(SITES_DIR, basenameOf(file).replace(/\.json$/, ''), 'index.html');
|
|
}
|
|
|
|
/** payload 디렉토리의 *.json 목록. 백엔드가 rename 전에 쓰는 임시파일(.tmp)은 건너뛴다. */
|
|
function listPayloads() {
|
|
if (!existsSync(PAYLOAD_DIR)) return [];
|
|
return readdirSync(PAYLOAD_DIR)
|
|
.filter((name) => name.endsWith('.json') && !name.startsWith('.'))
|
|
.map((name) => join(PAYLOAD_DIR, name));
|
|
}
|
|
|
|
// ── 기동 ──────────────────────────────────────────────────────────────────
|
|
async function main() {
|
|
mkdirSync(PAYLOAD_DIR, {recursive: true});
|
|
|
|
const {code} = await buildBundles();
|
|
if (code !== 0) {
|
|
warn('번들 빌드 실패 — 프리렌더를 할 수 없습니다. 컨테이너를 다시 띄우세요.');
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
// ★ 기동 시 전부 굽지 않는다(refreshAssets 주석). 자산 주소만 맞추고, 내용은
|
|
// 사장님이 다시 발행할 때 새 렌더러로 구워진다.
|
|
const all = listPayloads();
|
|
const seen = new Map(all.map((file) => [file, statSync(file).mtimeMs]));
|
|
await refreshAssets();
|
|
|
|
// 아직 한 번도 안 구워진 payload 는 굽는다 — 감시가 꺼져 있는 동안 발행됐거나 볼륨이
|
|
// 비어 있던 경우다. 사이트가 아예 없는 것과 "옛 내용으로 서 있는 것" 은 다른 문제다.
|
|
const unbuilt = all.filter((file) => !existsSync(bakedIndexOf(file)));
|
|
await prerender(unbuilt, '아직 안 구워진 것');
|
|
|
|
if (ONCE) return;
|
|
|
|
log(`감시 중: ${PAYLOAD_DIR} (바뀐 payload 만 굽습니다)`);
|
|
|
|
/**
|
|
* ★ fs.watch 를 쓰지 않는다. Docker 볼륨(bind mount)을 통해 들어온 변경은 macOS 에서
|
|
* inotify/FSEvents 이벤트가 오지 않는 경우가 있다 — 발행해도 아무 일이 안 일어난다.
|
|
* 폴링은 느리지만 확실하다.
|
|
*/
|
|
setInterval(() => {
|
|
for (const file of listPayloads()) {
|
|
let mtime;
|
|
try {
|
|
mtime = statSync(file).mtimeMs;
|
|
} catch {
|
|
continue; // 폴링과 rename 이 겹친 순간. 다음 틱에 다시 본다.
|
|
}
|
|
if (seen.get(file) === mtime) continue;
|
|
seen.set(file, mtime);
|
|
log(`${basenameOf(file)} 변경 감지`);
|
|
enqueue(file);
|
|
}
|
|
}, POLL_MS);
|
|
}
|
|
|
|
void main();
|