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 <noreply@anthropic.com>
This commit is contained in:
raphael.thieffry
2026-05-30 13:53:12 +02:00
parent 97f6fdaeae
commit fdce9e4eb8
5 changed files with 122 additions and 17 deletions

View File

@@ -9,7 +9,7 @@ app.use("*", logger());
app.use( app.use(
"*", "*",
cors({ cors({
origin: ["http://localhost:5173"], origin: (origin) => origin ?? "*",
allowMethods: ["GET", "POST", "OPTIONS"], allowMethods: ["GET", "POST", "OPTIONS"],
allowHeaders: ["Content-Type"], allowHeaders: ["Content-Type"],
}) })

18
backend/src/lib/redis.ts Normal file
View File

@@ -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";

View File

@@ -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 { prisma } from "../lib/prisma";
import { redisPub, redisSub, MESSAGES_CHANNEL } from "../lib/redis";
const messages = new Hono(); 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 // GET /api/messages — top-level threads with replies
messages.get("/", async (c) => { messages.get("/", async (c) => {
const data = await prisma.message.findMany({ const data = await prisma.message.findMany({
@@ -10,19 +23,44 @@ messages.get("/", async (c) => {
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
take: 50, take: 50,
include: { include: {
replies: { replies: { orderBy: { createdAt: "asc" } },
orderBy: { createdAt: "asc" },
},
}, },
}); });
return c.json(data); 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<void>((resolve) => {
stream.onAbort(() => {
clearInterval(ping);
sub.disconnect();
resolve();
});
});
})
);
// POST /api/messages — create a message or reply // POST /api/messages — create a message or reply
messages.post("/", async (c) => { messages.post("/", async (c) => {
const ip = const ip = clientIp(c);
c.req.header("x-forwarded-for")?.split(",")[0].trim() ?? "127.0.0.1";
const body = await c.req.json<{ content: string; parentId?: string }>(); const body = await c.req.json<{ content: string; parentId?: string }>();
if (!body.content || body.content.trim().length === 0) { 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); return c.json(message, 201);
}); });

View File

@@ -1,10 +1,11 @@
import { ref, onMounted } from 'vue'; import { ref, onMounted, onBeforeUnmount } from 'vue';
export interface Reply { export interface Reply {
id: string; id: string;
content: string; content: string;
authorIp: string; authorIp: string;
createdAt: string; createdAt: string;
parentId?: string | null;
} }
export interface Message extends Reply { export interface Message extends Reply {
@@ -18,13 +19,16 @@ export function useMessages() {
const messages = ref<Message[]>([]); const messages = ref<Message[]>([]);
const loading = ref(false); const loading = ref(false);
const sending = ref(false); const sending = ref(false);
const connected = ref(false);
let source: EventSource | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
async function fetchMessages(): Promise<void> { async function fetchMessages(): Promise<void> {
loading.value = true; loading.value = true;
try { try {
const res = await fetch(`${API_URL}/api/messages`); const res = await fetch(`${API_URL}/api/messages`);
if (res.ok) { 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(); messages.value = ((await res.json()) as Message[]).reverse();
} }
} finally { } finally {
@@ -32,24 +36,67 @@ export function useMessages() {
} }
} }
async function postMessage(content: string): Promise<boolean> { 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<boolean> {
if (!content.trim()) return false; if (!content.trim()) return false;
sending.value = true; sending.value = true;
try { try {
const res = await fetch(`${API_URL}/api/messages`, { const res = await fetch(`${API_URL}/api/messages`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: content.trim() }), body: JSON.stringify({ content: content.trim(), parentId }),
}); });
if (!res.ok) return false; return res.ok;
await fetchMessages();
return true;
} finally { } finally {
sending.value = false; 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 };
} }

View File

@@ -11,5 +11,6 @@ export default defineConfig({
}, },
server: { server: {
port: 5173, port: 5173,
host: true,
}, },
}); });