Skip to content

Commit 8cf72e8

Browse files
committed
written clean architecture for socket + added throttle method to reduce server load
1 parent c9301be commit 8cf72e8

3 files changed

Lines changed: 181 additions & 53 deletions

File tree

Lines changed: 138 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,152 @@
1-
import { useEffect, useRef } from "react";
2-
import { io, Socket } from "socket.io-client";
3-
/* eslint-disable @typescript-eslint/no-explicit-any */
4-
interface UseDocumentSocketParams {
5-
workspaceId: string;
6-
documentId: string;
7-
onContentChange: (content: string) => void;
8-
onCursorChange: (cursor: any) => void;
9-
}
1+
import { useCallback, useEffect, useRef, useState } from "react";
2+
import type {
3+
ContentChangePayload,
4+
CursorChangePayload,
5+
PresencePayload,
6+
} from "../types/realtime";
7+
import { createSocket, disconnectSocket, getSocket } from "../lib/socket";
8+
9+
export function useDocumentSocket(options: {
10+
workspaceId?: string | null;
11+
documentId?: string | undefined;
12+
onRemoteContent: (payload: ContentChangePayload) => void;
13+
onRemoteCursor?: (payload: CursorChangePayload) => void;
14+
}) {
15+
const { workspaceId, documentId, onRemoteContent, onRemoteCursor } = options;
1016

11-
export const useDocumentSocket = ({
12-
workspaceId,
13-
documentId,
14-
onContentChange,
15-
onCursorChange,
16-
}: UseDocumentSocketParams) => {
17-
const socketRef = useRef<Socket | null>(null);
17+
//maintaing a list of online user....
18+
const [onlineUsers, setOnlineUsers] = useState<string[]>([]);
19+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
20+
const socketRef = useRef<any>(null);
1821

22+
// throttle state to 200ms....
23+
const lastSentAt = useRef<number>(0);
24+
const pendingContent = useRef<string | null>(null);
25+
const timerRef = useRef<number | null>(null);
26+
const THROTTLE_MS = 200;
27+
28+
//connect and attach lstners..
1929
useEffect(() => {
20-
if (!workspaceId || !documentId) return;
21-
const socket = io("http://localhost:3000", {
22-
transports: ["websocket"],
23-
withCredentials: true,
24-
query: {
25-
workspaceId,
26-
documentId,
27-
},
28-
});
30+
if (!workspaceId || documentId) return;
2931

32+
const WS_URL = import.meta.env.VITE_API_URL
33+
? (import.meta.env.VITE_API_URL as string).replace(/\/api.*$/, "")
34+
: "http://localhost:3000";
35+
36+
const socket = createSocket(WS_URL, { workspaceId, documentId });
3037
socketRef.current = socket;
3138

32-
//listning to the sockets....
33-
socket.on("contentChange", (data) => {
34-
onContentChange(data.content);
39+
//connection logs....
40+
41+
socket.on("connect", () => {
42+
console.log("[socket] connected", socket.id);
3543
});
36-
socket.on("cursorChange", (data) => {
37-
onCursorChange?.(data);
44+
socket.on("connect_error", (err) => {
45+
console.warn("[socket] connect_error", err?.message ?? err);
3846
});
39-
socket.on("userJoined", (data) => {
40-
console.log("User Joined:", data);
47+
socket.on("disconnect", (reason) => {
48+
console.log("[socket] disconnected", reason);
4149
});
42-
socket.on("userLeft", (data) => {
43-
console.log("User Left:", data);
50+
51+
socket.on("contentChange", (payload: ContentChangePayload) => {
52+
if (!payload || typeof payload.content !== "string") return;
53+
onRemoteContent(payload);
4454
});
45-
return () => {
46-
socket.disconnect();
47-
};
48-
// eslint-disable-next-line react-hooks/exhaustive-deps
49-
}, [workspaceId, documentId]);
50-
51-
//send updates ..
52-
const sendDocumentUpdate = (newContent: string) => {
53-
socketRef.current?.emit("documentUpdate", {
54-
documentId,
55-
newContent,
55+
56+
socket.on("cursorChange", (payload: CursorChangePayload) => {
57+
if (!payload) return;
58+
onRemoteCursor?.(payload);
5659
});
57-
};
5860

59-
const sendCursorUpdate = (position: number) => {
60-
socketRef.current?.emit("cursorUpdate", {
61-
documentId,
62-
position,
61+
socket.on("userJoined", (payload: PresencePayload) => {
62+
if (!payload?.userId) return;
63+
setOnlineUsers((prev) => {
64+
if (prev.includes(payload.userId)) return prev;
65+
return [...prev, payload.userId];
66+
});
6367
});
64-
};
6568

66-
return { sendDocumentUpdate, sendCursorUpdate };
67-
};
69+
socket.on("userLeft", (payload: PresencePayload) => {
70+
if (!payload?.userId) return;
71+
setOnlineUsers((prev) => prev.filter((u) => u !== payload.userId));
72+
});
73+
74+
return () => {
75+
// cleanup: stop listeners and disconnect if this was the only usage
76+
socket.off("contentChange");
77+
socket.off("cursorChange");
78+
socket.off("userJoined");
79+
socket.off("userLeft");
80+
};
81+
}, [workspaceId, documentId, onRemoteContent, onRemoteCursor]);
82+
83+
const safeDisconnect = useCallback(() => {
84+
// remove all listeners and disconnect
85+
const s = getSocket();
86+
if (s) {
87+
s.off("contentChange");
88+
s.off("cursorChange");
89+
s.off("userJoined");
90+
s.off("userLeft");
91+
}
92+
disconnectSocket();
93+
socketRef.current = null;
94+
setOnlineUsers([]);
95+
}, []);
96+
97+
// Throttled document update: coalesce updates and send at most once/200ms
98+
const sendDocumentUpdate = useCallback(
99+
(newContent: string) => {
100+
const s = getSocket();
101+
if (!s) return;
102+
103+
const now = Date.now();
104+
const since = now - (lastSentAt.current || 0);
105+
106+
if (since >= THROTTLE_MS) {
107+
lastSentAt.current = now;
108+
s.emit("documentUpdate", { documentId, newContent });
109+
// clear pending
110+
if (timerRef.current) {
111+
window.clearTimeout(timerRef.current);
112+
timerRef.current = null;
113+
}
114+
pendingContent.current = null;
115+
return;
116+
}
117+
118+
// otherwise schedule latest content to be sent later
119+
pendingContent.current = newContent;
120+
if (timerRef.current) return;
121+
122+
const wait = THROTTLE_MS - since;
123+
timerRef.current = window.setTimeout(() => {
124+
const latest = pendingContent.current;
125+
if (latest != null) {
126+
const sock = getSocket();
127+
sock?.emit("documentUpdate", { documentId, newContent: latest });
128+
lastSentAt.current = Date.now();
129+
}
130+
pendingContent.current = null;
131+
timerRef.current = null;
132+
}, wait) as unknown as number;
133+
},
134+
[documentId]
135+
);
136+
137+
const sendCursorUpdate = useCallback(
138+
(position: number) => {
139+
const s = getSocket();
140+
if (!s) return;
141+
s.emit("cursorUpdate", { documentId, position });
142+
},
143+
[documentId]
144+
);
145+
146+
return {
147+
sendDocumentUpdate,
148+
sendCursorUpdate,
149+
onlineUsers,
150+
safeDisconnect,
151+
};
152+
}

Client/src/lib/socket.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { io, Socket } from "socket.io-client";
2+
3+
let socket: Socket | null = null;
4+
5+
export function createSocket(
6+
url: string,
7+
query: Record<string, string | undefined>
8+
) {
9+
if (socket) return socket;
10+
socket = io(url, {
11+
transports: ["websocket"],
12+
withCredentials: true,
13+
query,
14+
autoConnect: true,
15+
});
16+
return socket;
17+
}
18+
19+
export function getSocket() {
20+
return socket;
21+
}
22+
23+
export function disconnectSocket() {
24+
if (!socket) return;
25+
socket.disconnect();
26+
socket = null;
27+
}

Client/src/types/realtime.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export type ContentChangePayload = {
2+
userId?: string;
3+
content: string;
4+
timestamp?: string;
5+
};
6+
7+
export type CursorChangePayload = {
8+
userId?: string;
9+
position: number;
10+
timestamp?: string;
11+
};
12+
13+
export type PresencePayload = {
14+
userId: string;
15+
message?: string;
16+
};

0 commit comments

Comments
 (0)