From fdce9e4eb809e8cbb72c441f06a89e35ce521f3a Mon Sep 17 00:00:00 2001 From: "raphael.thieffry" Date: Sat, 30 May 2026 13:53:12 +0200 Subject: [PATCH] feat: live messages via SSE + real client IP - backend: SSE endpoint /api/messages/stream backed by Redis pub/sub - backend: read real client IP via getConnInfo (fallback for x-forwarded-for) - backend: CORS allow any origin (dev: LAN access from phone) - frontend: useMessages subscribes via EventSource, auto-reconnect, merges new messages/replies live - frontend: vite host:true to expose dev server on LAN Co-Authored-By: Claude Opus 4.7 --- backend/src/index.ts | 2 +- backend/src/lib/redis.ts | 18 +++++++ backend/src/routes/messages.ts | 53 +++++++++++++++++--- frontend/src/composables/useMessages.ts | 65 +++++++++++++++++++++---- frontend/vite.config.ts | 1 + 5 files changed, 122 insertions(+), 17 deletions(-) create mode 100644 backend/src/lib/redis.ts diff --git a/backend/src/index.ts b/backend/src/index.ts index 86b28d5..f2ea531 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -9,7 +9,7 @@ app.use("*", logger()); app.use( "*", cors({ - origin: ["http://localhost:5173"], + origin: (origin) => origin ?? "*", allowMethods: ["GET", "POST", "OPTIONS"], allowHeaders: ["Content-Type"], }) diff --git a/backend/src/lib/redis.ts b/backend/src/lib/redis.ts new file mode 100644 index 0000000..5c92ccf --- /dev/null +++ b/backend/src/lib/redis.ts @@ -0,0 +1,18 @@ +import Redis from "ioredis"; + +const URL = process.env.REDIS_URL ?? "redis://localhost:6379"; + +const globalForRedis = globalThis as unknown as { + redisPub?: Redis; + redisSub?: Redis; +}; + +export const redisPub = globalForRedis.redisPub ?? new Redis(URL); +export const redisSub = globalForRedis.redisSub ?? new Redis(URL); + +if (process.env.NODE_ENV !== "production") { + globalForRedis.redisPub = redisPub; + globalForRedis.redisSub = redisSub; +} + +export const MESSAGES_CHANNEL = "xip:messages"; diff --git a/backend/src/routes/messages.ts b/backend/src/routes/messages.ts index 50e6966..824d3c5 100644 --- a/backend/src/routes/messages.ts +++ b/backend/src/routes/messages.ts @@ -1,8 +1,21 @@ -import { Hono } from "hono"; +import { Hono, type Context } from "hono"; +import { streamSSE } from "hono/streaming"; +import { getConnInfo } from "hono/bun"; import { prisma } from "../lib/prisma"; +import { redisPub, redisSub, MESSAGES_CHANNEL } from "../lib/redis"; const messages = new Hono(); +function clientIp(c: Context): string { + const fwd = c.req.header("x-forwarded-for"); + if (fwd) return fwd.split(",")[0].trim(); + try { + return getConnInfo(c).remote.address ?? "0.0.0.0"; + } catch { + return "0.0.0.0"; + } +} + // GET /api/messages — top-level threads with replies messages.get("/", async (c) => { const data = await prisma.message.findMany({ @@ -10,19 +23,44 @@ messages.get("/", async (c) => { orderBy: { createdAt: "desc" }, take: 50, include: { - replies: { - orderBy: { createdAt: "asc" }, - }, + replies: { orderBy: { createdAt: "asc" } }, }, }); return c.json(data); }); +// GET /api/messages/stream — SSE live feed +messages.get("/stream", (c) => + streamSSE(c, async (stream) => { + const sub = redisSub.duplicate(); + await sub.subscribe(MESSAGES_CHANNEL); + + sub.on("message", (channel, payload) => { + if (channel !== MESSAGES_CHANNEL) return; + stream.writeSSE({ event: "message", data: payload }).catch(() => {}); + }); + + await stream.writeSSE({ event: "ready", data: "ok" }); + + const ping = setInterval(() => { + stream + .writeSSE({ event: "ping", data: String(Date.now()) }) + .catch(() => {}); + }, 25_000); + + await new Promise((resolve) => { + stream.onAbort(() => { + clearInterval(ping); + sub.disconnect(); + resolve(); + }); + }); + }) +); + // POST /api/messages — create a message or reply messages.post("/", async (c) => { - const ip = - c.req.header("x-forwarded-for")?.split(",")[0].trim() ?? "127.0.0.1"; - + const ip = clientIp(c); const body = await c.req.json<{ content: string; parentId?: string }>(); if (!body.content || body.content.trim().length === 0) { @@ -40,6 +78,7 @@ messages.post("/", async (c) => { }, }); + await redisPub.publish(MESSAGES_CHANNEL, JSON.stringify(message)); return c.json(message, 201); }); diff --git a/frontend/src/composables/useMessages.ts b/frontend/src/composables/useMessages.ts index a30719c..ab687f4 100644 --- a/frontend/src/composables/useMessages.ts +++ b/frontend/src/composables/useMessages.ts @@ -1,10 +1,11 @@ -import { ref, onMounted } from 'vue'; +import { ref, onMounted, onBeforeUnmount } from 'vue'; export interface Reply { id: string; content: string; authorIp: string; createdAt: string; + parentId?: string | null; } export interface Message extends Reply { @@ -18,13 +19,16 @@ export function useMessages() { const messages = ref([]); const loading = ref(false); const sending = ref(false); + const connected = ref(false); + + let source: EventSource | null = null; + let reconnectTimer: ReturnType | null = null; async function fetchMessages(): Promise { loading.value = true; try { const res = await fetch(`${API_URL}/api/messages`); if (res.ok) { - // L'API renvoie du plus récent au plus ancien ; on inverse pour affichage chronologique messages.value = ((await res.json()) as Message[]).reverse(); } } finally { @@ -32,24 +36,67 @@ export function useMessages() { } } - async function postMessage(content: string): Promise { + function applyIncoming(payload: Reply & { parentId: string | null }): void { + if (payload.parentId) { + const parent = messages.value.find((m) => m.id === payload.parentId); + if (!parent) return; + if (parent.replies.some((r) => r.id === payload.id)) return; + parent.replies.push(payload); + } else { + if (messages.value.some((m) => m.id === payload.id)) return; + messages.value.push({ ...payload, replies: [] }); + } + } + + function connect(): void { + if (source) source.close(); + source = new EventSource(`${API_URL}/api/messages/stream`); + + source.addEventListener('ready', () => { + connected.value = true; + }); + + source.addEventListener('message', (e) => { + try { + applyIncoming(JSON.parse((e as MessageEvent).data)); + } catch { + /* ignore malformed payload */ + } + }); + + source.onerror = () => { + connected.value = false; + source?.close(); + source = null; + reconnectTimer = setTimeout(connect, 2000); + }; + } + + async function postMessage(content: string, parentId?: string): Promise { if (!content.trim()) return false; sending.value = true; try { const res = await fetch(`${API_URL}/api/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content: content.trim() }), + body: JSON.stringify({ content: content.trim(), parentId }), }); - if (!res.ok) return false; - await fetchMessages(); - return true; + return res.ok; } finally { sending.value = false; } } - onMounted(fetchMessages); + onMounted(async () => { + await fetchMessages(); + connect(); + }); - return { messages, loading, sending, postMessage }; + onBeforeUnmount(() => { + if (reconnectTimer) clearTimeout(reconnectTimer); + source?.close(); + source = null; + }); + + return { messages, loading, sending, connected, postMessage, fetchMessages }; } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 90e51d8..aefbb53 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -11,5 +11,6 @@ export default defineConfig({ }, server: { port: 5173, + host: true, }, });