Phase 5: Real-time updates via Socket.IO
Wire Socket.IO broadcast from all write API routes so board changes propagate instantly to other connected clients. Add session-based auth on socket connections, in-memory presence tracking, and a presence UI in the board header showing who else is viewing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import express from 'express';
|
||||
import { createServer } from 'http';
|
||||
import { Server } from 'socket.io';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { handler } from './build/handler.js';
|
||||
|
||||
const app = express();
|
||||
@@ -13,26 +14,130 @@ const io = new Server(server, {
|
||||
}
|
||||
});
|
||||
|
||||
// Socket.IO auth + room management
|
||||
// ─── Prisma client for session validation ───────────────────
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// ─── Presence tracking ──────────────────────────────────────
|
||||
// Map<boardId, Map<socketId, { id, name }>>
|
||||
const presence = new Map();
|
||||
|
||||
function getPresenceList(boardId) {
|
||||
const room = presence.get(boardId);
|
||||
if (!room) return [];
|
||||
return Array.from(room.values());
|
||||
}
|
||||
|
||||
// ─── Cookie parser ──────────────────────────────────────────
|
||||
function parseCookies(cookieHeader) {
|
||||
const cookies = {};
|
||||
if (!cookieHeader) return cookies;
|
||||
cookieHeader.split(';').forEach((cookie) => {
|
||||
const [name, ...rest] = cookie.trim().split('=');
|
||||
cookies[name] = decodeURIComponent(rest.join('='));
|
||||
});
|
||||
return cookies;
|
||||
}
|
||||
|
||||
// ─── Socket.IO auth middleware ───────────────────────────────
|
||||
io.use(async (socket, next) => {
|
||||
try {
|
||||
const cookies = parseCookies(socket.handshake.headers.cookie);
|
||||
const sessionId = cookies['session'];
|
||||
if (!sessionId) {
|
||||
socket.data.user = null;
|
||||
return next();
|
||||
}
|
||||
|
||||
const session = await prisma.session.findUnique({
|
||||
where: { id: sessionId },
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, name: true, avatarUrl: true, tenantId: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (session && session.expiresAt > new Date()) {
|
||||
socket.data.user = session.user;
|
||||
} else {
|
||||
socket.data.user = null;
|
||||
}
|
||||
next();
|
||||
} catch (err) {
|
||||
console.error('Socket auth error:', err);
|
||||
socket.data.user = null;
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Socket.IO connection handler ────────────────────────────
|
||||
io.on('connection', (socket) => {
|
||||
console.log(`Socket connected: ${socket.id}`);
|
||||
const user = socket.data.user;
|
||||
console.log(`Socket connected: ${socket.id} (user: ${user?.name ?? 'anon'})`);
|
||||
|
||||
// Track which boards this socket has joined (for cleanup on disconnect)
|
||||
const joinedBoards = new Set();
|
||||
|
||||
socket.on('join-board', (boardId) => {
|
||||
socket.join(`board:${boardId}`);
|
||||
socket.to(`board:${boardId}`).emit('user-joined', { socketId: socket.id });
|
||||
joinedBoards.add(boardId);
|
||||
|
||||
if (user) {
|
||||
if (!presence.has(boardId)) presence.set(boardId, new Map());
|
||||
presence.get(boardId).set(socket.id, { id: user.id, name: user.name, avatarUrl: user.avatarUrl });
|
||||
}
|
||||
|
||||
// Notify others
|
||||
socket.to(`board:${boardId}`).emit('user-joined', {
|
||||
user: user ? { id: user.id, name: user.name, avatarUrl: user.avatarUrl } : null,
|
||||
socketId: socket.id
|
||||
});
|
||||
|
||||
// Send full presence list to everyone in the room (including the joiner)
|
||||
io.to(`board:${boardId}`).emit('presence:update', getPresenceList(boardId));
|
||||
});
|
||||
|
||||
socket.on('leave-board', (boardId) => {
|
||||
socket.leave(`board:${boardId}`);
|
||||
socket.to(`board:${boardId}`).emit('user-left', { socketId: socket.id });
|
||||
joinedBoards.delete(boardId);
|
||||
|
||||
if (presence.has(boardId)) {
|
||||
presence.get(boardId).delete(socket.id);
|
||||
if (presence.get(boardId).size === 0) presence.delete(boardId);
|
||||
}
|
||||
|
||||
socket.to(`board:${boardId}`).emit('user-left', {
|
||||
user: user ? { id: user.id, name: user.name } : null,
|
||||
socketId: socket.id
|
||||
});
|
||||
|
||||
// Update presence for remaining
|
||||
const room = presence.get(boardId);
|
||||
io.to(`board:${boardId}`).emit('presence:update', room ? Array.from(room.values()) : []);
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log(`Socket disconnected: ${socket.id}`);
|
||||
for (const boardId of joinedBoards) {
|
||||
if (presence.has(boardId)) {
|
||||
presence.get(boardId).delete(socket.id);
|
||||
if (presence.get(boardId).size === 0) {
|
||||
presence.delete(boardId);
|
||||
}
|
||||
}
|
||||
|
||||
socket.to(`board:${boardId}`).emit('user-left', {
|
||||
user: user ? { id: user.id, name: user.name } : null,
|
||||
socketId: socket.id
|
||||
});
|
||||
|
||||
const room = presence.get(boardId);
|
||||
io.to(`board:${boardId}`).emit('presence:update', room ? Array.from(room.values()) : []);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Store io globally so it can be accessed if needed
|
||||
// Store io globally so SvelteKit API routes can broadcast via realtime.ts
|
||||
globalThis.__socketIO = io;
|
||||
|
||||
// SvelteKit handler
|
||||
|
||||
Reference in New Issue
Block a user