Private
Public Access
1
0

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:
Catherine Renelle
2026-02-26 00:03:13 -05:00
parent 095d9756a8
commit e61c549ddb
23 changed files with 408 additions and 120 deletions
+110 -5
View File
@@ -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