fix: use connection.socket (ws.WebSocket) instead of Duplex stream for message handling

@fastify/websocket v8 passes Duplex stream as first arg, not ws.WebSocket directly.
socket.on('message') never fired because Duplex streams don't emit 'message' events.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ai
2026-05-22 13:38:14 +03:00
parent 21edd7f7a3
commit 86073c0549

View File

@@ -2,12 +2,12 @@ import { FastifyInstance } from 'fastify';
import { pool } from './db.js'; import { pool } from './db.js';
import webpush from 'web-push'; import webpush from 'web-push';
// userId -> Set of WebSocket-like connections // uid -> Set of WebSocket-like connections
export const connections = new Map<string, Set<any>>(); export const connections = new Map<string, Set<any>>();
function broadcast(userIds: string[], data: object) { function broadcast(uids: string[], data: object) {
const msg = JSON.stringify(data); const msg = JSON.stringify(data);
for (const uid of userIds) { for (const uid of uids) {
const sockets = connections.get(uid); const sockets = connections.get(uid);
if (sockets) { if (sockets) {
sockets.forEach((ws: any) => { sockets.forEach((ws: any) => {
@@ -26,9 +26,9 @@ async function getChatMemberIds(chatId: string): Promise<string[]> {
return rows.map((r: any) => r.user_id); return rows.map((r: any) => r.user_id);
} }
async function sendPushToOfflineUsers(userIds: string[], payload: object) { async function sendPushToOfflineUsers(uids: string[], payload: object) {
const online = new Set(connections.keys()); const online = new Set(connections.keys());
const offline = userIds.filter(id => !online.has(id)); const offline = uids.filter(id => !online.has(id));
if (offline.length === 0) return; if (offline.length === 0) return;
const { rows } = await pool.query( const { rows } = await pool.query(
@@ -52,23 +52,23 @@ async function sendPushToOfflineUsers(userIds: string[], payload: object) {
} }
export function setupWebSocket(app: FastifyInstance) { export function setupWebSocket(app: FastifyInstance) {
(app as any).get('/ws', { websocket: true }, (socket: any, req: any) => { (app as any).get('/ws', { websocket: true }, (connection: any, req: any) => {
let userId: string | null = null; // @fastify/websocket v8 passes a Duplex stream — actual ws.WebSocket is at connection.socket
let uid: string;
try { try {
const token = req.query?.token; const token = req.query?.token;
const decoded = app.jwt.verify<{ id: string }>(token); const decoded = app.jwt.verify<{ id: string }>(token);
userId = decoded.id; uid = decoded.id;
} catch { } catch {
socket.close(1008, 'Unauthorized'); connection.socket.close(1008, 'Unauthorized');
return; return;
} }
const uid = userId; const ws = connection.socket; // actual ws.WebSocket
// Register connection // Register connection (store ws.WebSocket for broadcast)
if (!connections.has(uid)) connections.set(uid, new Set()); if (!connections.has(uid)) connections.set(uid, new Set());
connections.get(uid)!.add(socket); connections.get(uid)!.add(ws);
// Update last_seen + notify online (fire-and-forget) // Update last_seen + notify online (fire-and-forget)
pool.query('UPDATE users SET last_seen = NOW() WHERE id = $1', [uid]).catch(() => {}); pool.query('UPDATE users SET last_seen = NOW() WHERE id = $1', [uid]).catch(() => {});
@@ -79,7 +79,7 @@ export function setupWebSocket(app: FastifyInstance) {
} }
}).catch(() => {}); }).catch(() => {});
socket.on('message', async (raw: any, isBinary: boolean) => { ws.on('message', async (raw: any, isBinary: boolean) => {
try { try {
const str = isBinary ? raw.toString('utf8') : raw.toString(); const str = isBinary ? raw.toString('utf8') : raw.toString();
console.log('[WS] received:', str.substring(0, 200)); console.log('[WS] received:', str.substring(0, 200));
@@ -87,28 +87,28 @@ export function setupWebSocket(app: FastifyInstance) {
if (type === 'typing') { if (type === 'typing') {
const members = await getChatMemberIds(payload.chatId); const members = await getChatMemberIds(payload.chatId);
broadcast(members.filter(id => id !== userId), { broadcast(members.filter(id => id !== uid), {
type: 'typing', type: 'typing',
payload: { chatId: payload.chatId, userId, typing: payload.typing } payload: { chatId: payload.chatId, userId: uid, typing: payload.typing }
}); });
} }
if (type === 'read_messages') { if (type === 'read_messages') {
await pool.query( await pool.query(
`UPDATE chat_members SET last_read_at = NOW() WHERE chat_id = $1 AND user_id = $2`, `UPDATE chat_members SET last_read_at = NOW() WHERE chat_id = $1 AND user_id = $2`,
[payload.chatId, userId] [payload.chatId, uid]
); );
const members = await getChatMemberIds(payload.chatId); const members = await getChatMemberIds(payload.chatId);
broadcast(members.filter(id => id !== userId), { broadcast(members.filter(id => id !== uid), {
type: 'messages_read', type: 'messages_read',
payload: { chatId: payload.chatId, userId } payload: { chatId: payload.chatId, userId: uid }
}); });
} }
if (type === 'send_message') { if (type === 'send_message') {
const { rows: [member] } = await pool.query( const { rows: [member] } = await pool.query(
'SELECT role, can_send_messages FROM chat_members WHERE chat_id = $1 AND user_id = $2', 'SELECT role, can_send_messages FROM chat_members WHERE chat_id = $1 AND user_id = $2',
[payload.chatId, userId] [payload.chatId, uid]
); );
if (!member) return; if (!member) return;
@@ -120,11 +120,11 @@ export function setupWebSocket(app: FastifyInstance) {
`INSERT INTO messages (chat_id, sender_id, content, type, reply_to_id) `INSERT INTO messages (chat_id, sender_id, content, type, reply_to_id)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
RETURNING *`, RETURNING *`,
[payload.chatId, userId, payload.content, payload.type || 'text', payload.replyToId || null] [payload.chatId, uid, payload.content, payload.type || 'text', payload.replyToId || null]
); );
const { rows: [sender] } = await pool.query( const { rows: [sender] } = await pool.query(
'SELECT id, username, display_name, avatar_color FROM users WHERE id = $1', [userId] 'SELECT id, username, display_name, avatar_color FROM users WHERE id = $1', [uid]
); );
const fullMsg = { const fullMsg = {
@@ -145,7 +145,7 @@ export function setupWebSocket(app: FastifyInstance) {
await pool.query( await pool.query(
'UPDATE chat_members SET last_read_at = NOW() WHERE chat_id = $1 AND user_id = $2', 'UPDATE chat_members SET last_read_at = NOW() WHERE chat_id = $1 AND user_id = $2',
[payload.chatId, userId] [payload.chatId, uid]
); );
} }
@@ -156,9 +156,9 @@ export function setupWebSocket(app: FastifyInstance) {
if (!msg) return; if (!msg) return;
const { rows: [member] } = await pool.query( const { rows: [member] } = await pool.query(
'SELECT role FROM chat_members WHERE chat_id = $1 AND user_id = $2', [msg.chat_id, userId] 'SELECT role FROM chat_members WHERE chat_id = $1 AND user_id = $2', [msg.chat_id, uid]
); );
if (msg.sender_id !== userId && !['owner', 'admin'].includes(member?.role)) return; if (msg.sender_id !== uid && !['owner', 'admin'].includes(member?.role)) return;
await pool.query('UPDATE messages SET is_deleted = TRUE WHERE id = $1', [payload.messageId]); await pool.query('UPDATE messages SET is_deleted = TRUE WHERE id = $1', [payload.messageId]);
const members = await getChatMemberIds(msg.chat_id); const members = await getChatMemberIds(msg.chat_id);
@@ -169,7 +169,7 @@ export function setupWebSocket(app: FastifyInstance) {
const { rows: [msg] } = await pool.query( const { rows: [msg] } = await pool.query(
'SELECT sender_id, chat_id FROM messages WHERE id = $1', [payload.messageId] 'SELECT sender_id, chat_id FROM messages WHERE id = $1', [payload.messageId]
); );
if (!msg || msg.sender_id !== userId) return; if (!msg || msg.sender_id !== uid) return;
const { rows: [updated] } = await pool.query( const { rows: [updated] } = await pool.query(
`UPDATE messages SET content = $1, is_edited = TRUE, edited_at = NOW() WHERE id = $2 `UPDATE messages SET content = $1, is_edited = TRUE, edited_at = NOW() WHERE id = $2
@@ -178,7 +178,7 @@ export function setupWebSocket(app: FastifyInstance) {
); );
const { rows: [sender] } = await pool.query( const { rows: [sender] } = await pool.query(
'SELECT id, username, display_name, avatar_color FROM users WHERE id = $1', [userId] 'SELECT id, username, display_name, avatar_color FROM users WHERE id = $1', [uid]
); );
const fullMsg = { const fullMsg = {
@@ -197,10 +197,10 @@ export function setupWebSocket(app: FastifyInstance) {
} }
}); });
socket.on('close', async () => { ws.on('close', async () => {
const set = connections.get(uid); const set = connections.get(uid);
if (set) { if (set) {
set.delete(socket); set.delete(ws);
if (set.size === 0) { if (set.size === 0) {
connections.delete(uid); connections.delete(uid);
await pool.query('UPDATE users SET last_seen = NOW() WHERE id = $1', [uid]); await pool.query('UPDATE users SET last_seen = NOW() WHERE id = $1', [uid]);
@@ -210,7 +210,7 @@ export function setupWebSocket(app: FastifyInstance) {
); );
for (const row of memberChats) { for (const row of memberChats) {
const ids = await getChatMemberIds(row.chat_id); const ids = await getChatMemberIds(row.chat_id);
broadcast(ids, { type: 'user_online', payload: { userId: uid, online: false } }); broadcast(ids, { type: 'user_online', payload: { uid: uid, online: false } });
} }
} }
} }