Files
gerbeur/api/services/ws-service.ts
2026-03-23 07:47:49 +00:00

206 lines
4.9 KiB
TypeScript

import type {
Comment,
Dump,
OnlineUser,
Playlist,
User,
} from "../model/interfaces.ts";
export interface WsClient {
socket: WebSocket;
userId?: string;
username?: string;
avatarMime?: string;
avatarVersion?: number;
pongReceived?: boolean;
}
const clients = new Set<WsClient>();
export function register(client: WsClient): void {
clients.add(client);
}
export function unregister(client: WsClient): void {
clients.delete(client);
}
export function updateClientAvatar(userId: string, avatarMime: string): void {
const version = Date.now();
for (const client of clients) {
if (client.userId === userId) {
client.avatarMime = avatarMime;
client.avatarVersion = version;
}
}
broadcastPresence();
}
export function getOnlineUsers(): OnlineUser[] {
const seen = new Map<string, OnlineUser>();
for (const client of clients) {
if (client.userId && !seen.has(client.userId)) {
seen.set(client.userId, {
userId: client.userId,
username: client.username!,
hasAvatar: !!client.avatarMime,
avatarVersion: client.avatarVersion,
});
}
}
return Array.from(seen.values());
}
function send(socket: WebSocket, data: unknown): void {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(data));
}
}
export function sendToUser(userId: string, data: unknown): void {
for (const client of clients) {
if (client.userId === userId) {
send(client.socket, data);
}
}
}
export function broadcastPresence(): void {
const users = getOnlineUsers();
for (const client of clients) {
send(client.socket, { type: "presence_update", users });
}
}
export function broadcastNewDump(dump: Dump): void {
for (const client of clients) {
send(client.socket, { type: "dump_created", dump });
}
}
export function broadcastDumpUpdated(dump: Dump): void {
for (const client of clients) {
send(client.socket, { type: "dump_updated", dump });
}
}
export function broadcastDumpDeleted(dumpId: string): void {
for (const client of clients) {
send(client.socket, { type: "dump_deleted", dumpId });
}
}
export function broadcastVoteUpdate(
dumpId: string,
voteCount: number,
voterId: string,
action: "cast" | "remove",
): void {
for (const client of clients) {
send(client.socket, {
type: "votes_update",
dumpId,
voteCount,
voterId,
action,
});
}
}
function sendToPlaylistAudience(
playlist: Pick<Playlist, "isPublic" | "userId">,
data: unknown,
): void {
for (const client of clients) {
if (playlist.isPublic || client.userId === playlist.userId) {
send(client.socket, data);
}
}
}
export function broadcastPlaylistCreated(playlist: Playlist): void {
sendToPlaylistAudience(playlist, { type: "playlist_created", playlist });
}
export function broadcastPlaylistUpdated(playlist: Playlist): void {
// Broadcast to ALL clients so non-owners can react to visibility changes
// (e.g. remove a now-private playlist from their feed).
for (const client of clients) {
send(client.socket, { type: "playlist_updated", playlist });
}
}
export function broadcastPlaylistDeleted(
playlistId: string,
userId: string,
isPublic: boolean,
): void {
sendToPlaylistAudience({ isPublic, userId }, {
type: "playlist_deleted",
playlistId,
userId,
});
}
export function broadcastPlaylistDumpsUpdated(
playlist: Playlist,
dumpIds: string[],
): void {
sendToPlaylistAudience(playlist, {
type: "playlist_dumps_updated",
playlistId: playlist.id,
dumpIds,
});
}
export function broadcastUserUpdated(user: Omit<User, "passwordHash">): void {
for (const client of clients) {
send(client.socket, { type: "user_updated", user });
}
}
export function broadcastCommentCreated(comment: Comment): void {
for (const client of clients) {
send(client.socket, { type: "comment_created", comment });
}
}
export function broadcastCommentDeleted(
commentId: string,
dumpId: string,
): void {
for (const client of clients) {
send(client.socket, { type: "comment_deleted", commentId, dumpId });
}
}
export function broadcastCommentUpdated(comment: Comment): void {
for (const client of clients) {
send(client.socket, { type: "comment_updated", comment });
}
}
export function handleClientPong(client: WsClient): void {
client.pongReceived = true;
}
// Keepalive: ping all clients every 30s, disconnect non-responsive ones
const PING_INTERVAL = 30_000;
setInterval(() => {
for (const client of clients) {
if (client.socket.readyState !== WebSocket.OPEN) {
clients.delete(client);
continue;
}
// Disconnect if no pong since last ping (pongReceived starts undefined, skip first cycle)
if (client.pongReceived === false) {
client.socket.close(1001, "Ping timeout");
clients.delete(client);
continue;
}
client.pongReceived = false;
send(client.socket, { type: "ping" });
}
}, PING_INTERVAL);