sipher/src/lib/sockets/index.ts
Nixyan 45301ac52b Add friend request functionality and user status updates
- Implemented a modal for sending and managing friend requests, allowing users to send, accept, decline, or ignore requests.
- Enhanced user status management by integrating real-time updates for online, busy, offline, and away statuses.
- Updated the API and database schema to support new friend request and user status features.
- Improved socket management for better connection handling and user experience.
- Refactored UI components to accommodate new functionalities while maintaining consistency.
2025-12-28 01:10:31 -03:00

219 lines
No EOL
7.1 KiB
TypeScript

/**
* @fileoverview Socket Manager Class for handling socket connections and events at the server side.
*/
import { Session, User } from "better-auth";
import { existsSync, readdirSync } from "fs";
import type { Server as HTTPServer } from "http";
import path from "path";
import { Socket, Server as SocketIOServer } from "socket.io";
import { pathToFileURL } from "url";
import z from "zod";
interface SocketManagerOptions {
/** Enable authentication via Better Auth (default: false) */
requireAuth?: boolean;
/** Base URL for Better Auth API (default: http://localhost:3000) */
authBaseUrl?: string;
/**
* Authentication method:
* - "session": Use existing session cookie (recommended for web clients)
* - "ott": Use one-time token (for non-browser clients or cross-origin)
*/
authMethod?: "session" | "ott";
}
export default class SocketManager {
private socketIo: SocketIOServer | null = null;
private events: Map<string, SiPher.EventsType[]> = new Map();
private options: SocketManagerOptions;
constructor(nextServer: HTTPServer, options: SocketManagerOptions = {}) {
if (!nextServer) {
throw new Error("Next server is required to create a SocketManager")
}
this.options = {
requireAuth: false,
authBaseUrl: process.env.SITE_URL || "http://localhost:3000",
authMethod: "session",
...options
};
if (!this.socketIo) {
this.socketIo = new SocketIOServer(nextServer, {
// Configure Socket.IO's built-in heartbeat mechanism
pingInterval: 25000, // Server sends ping every 25 seconds
pingTimeout: 60000, // Close connection if no pong received within 60 seconds
});
}
if (this.options.requireAuth) {
this.setupAuthMiddleware();
}
}
private setupAuthMiddleware(): void {
if (!this.socketIo) return;
this.socketIo.use(async (socket, next) => {
try {
let result: { user?: User, session?: Session } | null = null;
if (this.options.authMethod === "ott") {
// OTT-based auth: client must provide token in auth object
const token = socket.handshake.auth.token;
if (!token) {
return next(new Error("Authentication error: No token provided"));
}
const response = await fetch(`${this.options.authBaseUrl}/api/auth/one-time-token/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token })
});
if (!response.ok) {
return next(new Error("Authentication error: Invalid token"));
}
result = await response.json();
} else {
// Session-based auth: use cookies from handshake
const cookies = socket.handshake.headers.cookie || "";
const response = await fetch(`${this.options.authBaseUrl}/api/auth/get-session`, {
method: "GET",
headers: { "Cookie": cookies }
});
if (!response.ok) {
return next(new Error("Authentication error: No valid session"));
}
result = await response.json();
}
if (!result || !result.user) {
return next(new Error("Authentication error: Invalid session"));
}
const { user, session } = result;
// Set socket.id to user ID for persistent identification
// @ts-expect-error: This should be a readonly property, but IDGAF, if it breaks, it breaks :D
socket.id = user.id;
// Attach user and session to socket for use in event handlers
socket.user = user;
socket.session = session;
next();
} catch (error) {
console.error("[SocketManager] Auth error:", error);
return next(new Error("Authentication error"));
}
});
}
public getSocketIo(): SocketIOServer {
if (!this.socketIo) {
throw new Error("SocketIO server is not initialized")
}
return this.socketIo
}
/** Emit to a specific user by their user ID */
public emitToUser(userId: string, event: string, ...args: unknown[]): void {
this.socketIo?.to(`user:${userId}`).emit(event, ...args);
}
/** Emit to a global/fixed room */
public emitToRoom(roomId: string, event: string, ...args: unknown[]): void {
this.socketIo?.to(roomId).emit(event, ...args);
}
/** Get a socket by user ID (socket.id = user.id after auth) */
public getSocketByUserId(userId: string): Socket | undefined {
return this.socketIo?.sockets.sockets.get(userId);
}
public async initializeEventHandler(): Promise<void> {
// Get events from the events folder
const socketIo = this.getSocketIo();
const eventsFolderPath = path.join(process.cwd(), "src", "lib", "sockets", "events");
console.log(`[SocketManager] Events folder path: ${eventsFolderPath}`)
if (!existsSync(eventsFolderPath)) {
console.warn(`[SocketManager] Events folder not found: ${eventsFolderPath}`)
return
}
const eventFiles = readdirSync(eventsFolderPath)
.filter((file: string) => file.endsWith(".ts") || file.endsWith(".js"))
const eventValidator = z.object({
name: z.string({ error: "Event 'name' must be a string" }),
handler: z.function(), // Validates it's a function; args are flexible
description: z.string({ error: "Event 'description' must be a string" }),
category: z.enum(["user", "group", "regional", "global", "server", "system"], {
error: "Event 'category' must be one of: user, group, regional, global, server, system",
}),
type: z.enum(["message", "connection", "disconnection", "error", "custom"], {
error: "Event 'type' must be one of: message, connection, disconnection, error, custom",
}),
}, {
error: "Event file must export a default object with: name, handler, description, category, type",
});
for (const file of eventFiles) {
try {
const filePath = path.join(eventsFolderPath, file)
const fileURL = pathToFileURL(filePath).href
const event = await import(fileURL).then(module => module.default)
const validatedEvent = eventValidator.safeParse(event)
if (!validatedEvent.success) {
console.error(`[SocketManager] Invalid event file: ${file}`, validatedEvent.error.issues)
console.error(`[SocketManager] Discarding event file: ${file}`)
continue
}
const data = validatedEvent.data as SiPher.EventsType;
// Group handlers by event name (what client emits)
const handlers = this.events.get(data.name) || []
handlers.push(data);
this.events.set(data.name, handlers);
console.log(`[SocketManager] Loaded event handler: ${data.name} (${data.category}/${data.type})`)
} catch (error) {
console.error(`[SocketManager] Failed to load event file: ${file}`, error)
}
}
// Register all events with Socket.IO
socketIo.on("connection", (socket) => {
const user = (socket as any).user;
console.log(`[SocketManager] Client connected: ${socket.id}${user ? ` (${user.email})` : ""}`);
// Register all event handlers by name
for (const [eventName, handlers] of this.events) {
for (const handler of handlers) {
socket.on(eventName, (...args) => {
try {
handler.handler(socket, socketIo, ...args)
} catch (error) {
console.error(`[SocketManager] Error in ${handler.name}:`, error)
}
})
}
}
// Handle disconnect within the connection context
socket.on("disconnect", (reason) => {
console.log(`[SocketManager] Client disconnected: ${socket.id} (${reason})`);
});
})
}
}