- 오른쪽 상단에 한 줄씩 뜨는 반투명 채팅, 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>
62 라인
1.8 KiB
TypeScript
62 라인
1.8 KiB
TypeScript
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",
|
|
},
|
|
});
|
|
}
|