여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제 - 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다 - eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다 파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이). site·frontend·admin tsc, site vitest 105 passed Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
210 lines
7.5 KiB
JavaScript
210 lines
7.5 KiB
JavaScript
/** payload 감시 → 자동 프리렌더. */
|
|
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');
|
|
|
|
/** 폴링 간격. */
|
|
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}` 를 돌려준다. */
|
|
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});
|
|
});
|
|
});
|
|
}
|
|
|
|
/** 프리렌더가 **자기 보고서를 쓰지도 못하고 죽었을 때** 대신 실패를 남긴다. */
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
/** 클라이언트 번들 + 프리렌더 번들을 만든다. */
|
|
/** 클라이언트 번들 → 프리렌더 번들. */
|
|
async function buildBundles() {
|
|
const client = await run('npm', ['run', 'build:client'], '클라이언트 번들');
|
|
if (client.code !== 0) return client;
|
|
return run('npm', ['run', 'build:prerender'], '프리렌더 번들');
|
|
}
|
|
|
|
/** payload 파일들을 굽는다. */
|
|
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);
|
|
}
|
|
|
|
/** 기동 때 공용 자산(out/assets · out/fonts · public/)만 채워 둔다. */
|
|
function seedAssets() {
|
|
return run('node', [PRERENDER_JS, '--seed-assets'], '공용 자산 시딩 — 기동');
|
|
}
|
|
|
|
// ── 큐 ────────────────────────────────────────────────────────────────────
|
|
/** 굽기를 기다리는 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(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 가 이미 구워져 있나. */
|
|
function bakedIndexOf(file) {
|
|
return join(SITES_DIR, basenameOf(file).replace(/\.json$/, ''), 'index.html');
|
|
}
|
|
|
|
/** payload 디렉토리의 *.json 목록. */
|
|
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;
|
|
}
|
|
|
|
// 기동 시 전부 굽지 않는다(seedAssets 주석).
|
|
const all = listPayloads();
|
|
const seen = new Map(all.map((file) => [file, statSync(file).mtimeMs]));
|
|
await seedAssets();
|
|
|
|
// 아직 한 번도 안 구워진 payload 는 굽는다 — 감시가 꺼져 있는 동안 발행됐거나 볼륨이 비어 있던 경우다.
|
|
const unbuilt = all.filter((file) => !existsSync(bakedIndexOf(file)));
|
|
await prerender(unbuilt, '아직 안 구워진 것');
|
|
|
|
if (ONCE) return;
|
|
|
|
log(`감시 중: ${PAYLOAD_DIR} (바뀐 payload 만 굽습니다)`);
|
|
|
|
/** fs.watch 를 쓰지 않는다. */
|
|
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();
|