실시간 마을 채팅 티커

- 오른쪽 상단에 한 줄씩 뜨는 반투명 채팅, 20초 후 페이드아웃 (최대 6줄)
- 💬 버튼으로 입력창 토글, Enter 전송 / ESC 닫기
- SSE 스트림(/api/chat/stream): 접속 즉시 연결, 최근 20개 백로그는 비동기 전송,
  25초 하트비트로 프록시 유휴 타임아웃 방지
- 전송(/api/chat/send): 닉네임+토큰 검증(사칭 방지), 1.5초 도배 제한, 120자 제한
- chat_messages 테이블 자동 생성, 인프로세스 EventEmitter 브로드캐스트 (단일 컨테이너)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
kjs
2026-08-14 16:38:25 +09:00
co-authored by Claude Fable 5
부모 7ae93bee91
커밋 ad157bc4fe
5개의 변경된 파일과 320개의 추가작업 그리고 0개의 파일을 삭제
+61
파일 보기
@@ -0,0 +1,61 @@
import { onChat, recentChat, type ChatMessage } from "@/lib/chat";
export const dynamic = "force-dynamic";
/** SSE: 접속 시 최근 20개 → 이후 새 메시지 실시간 푸시 */
export async function GET(req: Request) {
const encoder = new TextEncoder();
let unsubscribe: (() => void) | null = null;
let heartbeat: ReturnType<typeof setInterval> | null = null;
const stream = new ReadableStream({
async start(controller) {
const push = (msg: ChatMessage) => {
try {
controller.enqueue(
encoder.encode(`id: ${msg.id}\ndata: ${JSON.stringify(msg)}\n\n`),
);
} catch {
/* 이미 닫힘 */
}
};
// 실시간 구독을 먼저 연결 (백로그 DB 조회가 느려도 스트림은 즉시 열림)
unsubscribe = onChat(push);
controller.enqueue(encoder.encode(": connected\n\n"));
void recentChat(20)
.then((backlog) => backlog.forEach(push))
.catch(() => {
/* DB 불가 시 백로그 생략, 실시간만 */
});
// Traefik/프록시 유휴 타임아웃 방지
heartbeat = setInterval(() => {
try {
controller.enqueue(encoder.encode(": ping\n\n"));
} catch {
/* ignore */
}
}, 25000);
req.signal.addEventListener("abort", () => {
unsubscribe?.();
if (heartbeat) clearInterval(heartbeat);
try {
controller.close();
} catch {
/* ignore */
}
});
},
cancel() {
unsubscribe?.();
if (heartbeat) clearInterval(heartbeat);
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
}