Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions build/asset-manifest.json
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
{
"files": {
"main.css": "/static/css/main.42b54382.css",
"main.js": "/static/js/main.82214ec4.js",
"main.css": "/static/css/main.9809cef7.css",
"main.js": "/static/js/main.24668c71.js",
"static/js/453.5620f6ad.chunk.js": "/static/js/453.5620f6ad.chunk.js",
"index.html": "/index.html",
"main.42b54382.css.map": "/static/css/main.42b54382.css.map",
"main.82214ec4.js.map": "/static/js/main.82214ec4.js.map",
"main.9809cef7.css.map": "/static/css/main.9809cef7.css.map",
"main.24668c71.js.map": "/static/js/main.24668c71.js.map",
"453.5620f6ad.chunk.js.map": "/static/js/453.5620f6ad.chunk.js.map"
},
"entrypoints": [
"static/css/main.42b54382.css",
"static/js/main.82214ec4.js"
"static/css/main.9809cef7.css",
"static/js/main.24668c71.js"
]
}
2 changes: 1 addition & 1 deletion build/index.html
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.png"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Web site created using create-react-app"/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>CollabCE</title><script defer="defer" src="/static/js/main.82214ec4.js"></script><link href="/static/css/main.42b54382.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.png"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Web site created using create-react-app"/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>CollabCE</title><script defer="defer" src="/static/js/main.24668c71.js"></script><link href="/static/css/main.9809cef7.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
2 changes: 0 additions & 2 deletions build/static/css/main.42b54382.css

This file was deleted.

1 change: 0 additions & 1 deletion build/static/css/main.42b54382.css.map

This file was deleted.

3 changes: 0 additions & 3 deletions build/static/js/main.82214ec4.js

This file was deleted.

87 changes: 0 additions & 87 deletions build/static/js/main.82214ec4.js.LICENSE.txt

This file was deleted.

1 change: 0 additions & 1 deletion build/static/js/main.82214ec4.js.map

This file was deleted.

38 changes: 32 additions & 6 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ io.on('connection', (socket) => {
if (!roomState[roomId]) return;
// Server-side permission check — reject read-only users
if (!canWriteToRoom(socket, roomId)) {
socket.emit('error', { message: 'You do not have permission to modify files in this room.' });
socket.emit(ACTIONS.PERMISSION_DENIED, { message: 'You do not have permission to modify files in this room.' });
return;
}
const fs = roomState[roomId].fileSystem;
Expand All @@ -146,7 +146,7 @@ io.on('connection', (socket) => {
if (!roomState[roomId]) return;
// Server-side permission check — reject read-only users
if (!canWriteToRoom(socket, roomId)) {
socket.emit('error', { message: 'You do not have permission to modify files in this room.' });
socket.emit(ACTIONS.PERMISSION_DENIED, { message: 'You do not have permission to modify files in this room.' });
return;
}
const fs = roomState[roomId].fileSystem;
Expand Down Expand Up @@ -174,7 +174,7 @@ io.on('connection', (socket) => {
if (!roomState[roomId]) return;
// Server-side permission check — reject read-only users
if (!canWriteToRoom(socket, roomId)) {
socket.emit('error', { message: 'You do not have permission to modify files in this room.' });
socket.emit(ACTIONS.PERMISSION_DENIED, { message: 'You do not have permission to modify files in this room.' });
return;
}
const fs = roomState[roomId].fileSystem;
Expand All @@ -192,18 +192,22 @@ io.on('connection', (socket) => {
const reply = (success, message) => { if (typeof ack === 'function') ack({ success, message }); };

if (!roomState[roomId]) { reply(false, 'Room not found.'); return; }
// Server-side permission check — reject read-only users
// Server-side permission check — reject read-only users.
// Only reply via the ack callback (not a separate socket.emit) so the
// client receives exactly one error signal per failure — the ack handler
// in EditorPage.js already shows an error toast.
if (!canWriteToRoom(socket, roomId)) {
socket.emit('error', { message: 'You do not have permission to upload files in this room.' });
reply(false, 'You do not have permission to upload files in this room.');
return;
}

const fs = roomState[roomId].fileSystem;

// Merge nodes into file system in the order provided (folders before files)
// Only reply via the ack callback — do NOT also emit INVALID_PAYLOAD,
// because the client's ack callback already shows an error toast and
// emitting both would cause a double-toast UX regression.
if (!Array.isArray(nodes)) {
socket.emit('error', { message: 'Invalid upload payload: nodes must be an array.' });
reply(false, 'Invalid upload payload: nodes must be an array.');
return;
}
Expand Down Expand Up @@ -281,7 +285,29 @@ io.on('connection', (socket) => {
});
});

// ---- Per-socket sliding-window rate limiter for CODE_CHANGE ----
// Prevents a single client from flooding the room with more than
// CODE_CHANGE_LIMIT events within any rolling CODE_CHANGE_WINDOW ms.
// Uses a true sliding window (timestamp queue) instead of a fixed window
// to avoid the boundary-burst problem where a fixed window allows up to
// 2× the limit when events straddle two adjacent windows.
// Events beyond the limit are silently dropped — the Yjs CRDT layer will
// converge state anyway.
const CODE_CHANGE_LIMIT = 30; // max events per rolling window per socket
const CODE_CHANGE_WINDOW = 1000; // rolling window size in ms
const socketRateTimestamps = []; // circular buffer of recent event timestamps

socket.on(ACTIONS.CODE_CHANGE, ({ roomId, code }) => {
const now = Date.now();
// Evict timestamps that have slid out of the window
while (socketRateTimestamps.length > 0 && now - socketRateTimestamps[0] > CODE_CHANGE_WINDOW) {
socketRateTimestamps.shift();
}
if (socketRateTimestamps.length >= CODE_CHANGE_LIMIT) {
// Silently drop — Yjs will handle consistency
return;
}
socketRateTimestamps.push(now);
socket.to(roomId).emit(ACTIONS.CODE_CHANGE, { code });
});

Expand Down
8 changes: 8 additions & 0 deletions src/Actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ const ACTIONS = {
FS_RENAME_NODE: 'fs_rename_node',
// Uploads: sends nodes + file contents in one batch so content syncs to all collaborators
FS_UPLOAD_BATCH: 'fs_upload_batch',
// Server → client: permission-denied feedback.
// Uses a dedicated event name instead of Socket.IO's reserved 'error'
// which may fire with different payload shapes from the transport layer.
PERMISSION_DENIED: 'permission_denied',
// Server → client: malformed or invalid request payload.
// Semantically distinct from PERMISSION_DENIED — this signals a client
// bug (bad data shape) rather than an authorization failure.
INVALID_PAYLOAD: 'invalid_payload',
};

module.exports = ACTIONS;
84 changes: 80 additions & 4 deletions src/components/Editor.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { createThrottle, createDebounce } from '../utils/throttle';
import Codemirror from 'codemirror';
import 'codemirror/lib/codemirror.css';
import 'codemirror/theme/dracula.css';
Expand Down Expand Up @@ -67,6 +68,32 @@ function getColor(clientId) {
return cursorColors[Math.abs(clientId) % cursorColors.length];
}

// ── Throttle / debounce intervals (ms) ──────────────────────────────────
//
// These constants control three independent rate-limiters that sit between
// the raw editor/awareness events and the work those events trigger.
// Each value was chosen to balance perceived latency against CPU / network
// savings:
//
// Cursor broadcasts — 50 ms ≈ 20 updates/sec. Fast enough that
// remote cursors look smooth; slow enough to cut
// outbound WebSocket frames by ~90% during fast
// arrow-key navigation or click-drag selections.
//
// Awareness DOM work — 80 ms. Rebuilding bookmark widgets (DOM nodes)
// for every remote cursor micro-update is the most
// expensive main-thread work. 80 ms keeps remote
// cursors visually fluid without layout thrashing.
//
// Code-change state — 300 ms debounce. The snapshot (React setState)
// is only used for downloads / "run" — it doesn't
// drive the editing UI. 300 ms of trailing
// debounce eliminates hundreds of redundant renders
// during fast typing with negligible user impact.
const CURSOR_THROTTLE_MS = 50;
const AWARENESS_THROTTLE_MS = 80;
const CODE_CHANGE_DEBOUNCE_MS = 300;

const Editor = ({ socketRef, roomId, fileId, fileName, onCodeChange, userName, canWrite, editorTheme, onEditorReady, initialContent }) => {
const editorRef = useRef(null);
const textareaRef = useRef(null);
Expand All @@ -76,6 +103,11 @@ const Editor = ({ socketRef, roomId, fileId, fileName, onCodeChange, userName, c
const timeoutsRef = useRef(new Map());
const [peers, setPeers] = useState([]);

// Refs for rate-limiters so we can cancel them on cleanup
const cursorThrottleRef = useRef(null);
const awarenessThrottleRef = useRef(null);
const codeChangeDebounceRef = useRef(null);

// Enforce readOnly dynamically
useEffect(() => {
if (editorRef.current) {
Expand Down Expand Up @@ -131,8 +163,17 @@ const Editor = ({ socketRef, roomId, fileId, fileName, onCodeChange, userName, c
});
editorRef.current = editor;

// Debounce code-change state snapshots — the ref (fileContentsRef) is
// updated immediately by the parent, but the expensive setState that
// triggers a re-render is deferred so rapid keystrokes don't cause
// cascading React renders.
const codeChangeDebounce = createDebounce((fId, value) => {
if (onCodeChange) onCodeChange(fId, value);
}, CODE_CHANGE_DEBOUNCE_MS);
codeChangeDebounceRef.current = codeChangeDebounce;

editor.on('change', (instance) => {
if (onCodeChange) onCodeChange(fileId, instance.getValue());
codeChangeDebounce.call(fileId, instance.getValue());
});

if (onEditorReady) onEditorReady(fileId, editor);
Expand Down Expand Up @@ -166,7 +207,11 @@ const Editor = ({ socketRef, roomId, fileId, fileName, onCodeChange, userName, c
const myColor = getColor(awareness.clientID);
awareness.setLocalStateField('user', { name: userName || 'Anonymous', color: myColor });

awareness.on('change', () => {
// Throttle the awareness-change handler — this performs DOM manipulation
// (creating/clearing bookmark widgets) which is the most expensive work
// on the main thread. Without throttling, every remote keystroke triggers
// a full bookmark rebuild for all peers.
const awarenessThrottle = createThrottle(() => {
const states = awareness.getStates();
const activeClients = new Set();
const currentPeers = [];
Expand Down Expand Up @@ -211,15 +256,46 @@ const Editor = ({ socketRef, roomId, fileId, fileName, onCodeChange, userName, c
}
}
}
}, AWARENESS_THROTTLE_MS);
awarenessThrottleRef.current = awarenessThrottle;

awareness.on('change', () => {
awarenessThrottle.call();
});

editor.on('cursorActivity', () => {
// Throttle cursor-position broadcasts — during fast arrow-key navigation
// or click-drag selections the cursor fires dozens of events per second.
// Throttling to ~20 updates/sec keeps remote cursors smooth while
// cutting outbound WebSocket frames dramatically.
const cursorThrottle = createThrottle(() => {
const anchor = editor.getCursor('anchor');
const head = editor.getCursor('head');
awareness.setLocalStateField('customCursor', { anchor, head });
}, CURSOR_THROTTLE_MS);
cursorThrottleRef.current = cursorThrottle;

editor.on('cursorActivity', () => {
cursorThrottle.call();
});

return () => {
// Cancel all rate-limiters to prevent stale callbacks after unmount.
// Order matters: flush code-change first (to capture latest content
// for downloads), then cancel the others (cursor/awareness have no
// meaningful "last value" to preserve).
if (codeChangeDebounceRef.current) {
// Flush pending code-change so the latest content is captured
codeChangeDebounceRef.current.flush();
codeChangeDebounceRef.current = null;
}
if (cursorThrottleRef.current) {
cursorThrottleRef.current.cancel();
cursorThrottleRef.current = null;
}
if (awarenessThrottleRef.current) {
awarenessThrottleRef.current.cancel();
awarenessThrottleRef.current = null;
}
if (bindingRef.current) { bindingRef.current.destroy(); bindingRef.current = null; }
if (providerRef.current) { providerRef.current.destroy(); providerRef.current = null; }
if (editorRef.current) { editorRef.current.toTextArea(); editorRef.current = null; }
Expand Down
19 changes: 19 additions & 0 deletions src/pages/EditorPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,20 @@ const EditorPage = () => {
toast.error(message);
});

// Show a warning toast when the server rejects a request due to
// a malformed payload (e.g. missing required fields). Distinct
// from PERMISSION_DENIED so client code can tell auth failures
// apart from input-validation failures.
socketRef.current.on(ACTIONS.INVALID_PAYLOAD, (payload) => {
const message =
typeof payload === 'string'
? payload
: payload && typeof payload === 'object' && 'message' in payload
? payload.message
: 'Invalid request.';
toast.error(message);
});

Comment on lines +245 to +254
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR UX Double error toast when FS_UPLOAD_BATCH receives invalid nodes payload

server.js:205-208 emits INVALID_PAYLOAD AND calls reply(false, …) for the same validation failure. EditorPage.js handles both: the INVALID_PAYLOAD socket listener (line 245) fires toast.error, and the FS_UPLOAD_BATCH ack callback (line 567) also fires toast.error — showing two identical toasts.

Suggested change
socketRef.current.on(ACTIONS.INVALID_PAYLOAD, (payload) => {
const message =
typeof payload === 'string'
? payload
: payload && typeof payload === 'object' && 'message' in payload
? payload.message
: 'Invalid request.';
toast.error(message);
});
socketRef.current.on(ACTIONS.INVALID_PAYLOAD, (payload) => {
// Only handle INVALID_PAYLOAD events that are NOT from FS_UPLOAD_BATCH,
// which already surfaces the error via its ack callback.
const message =
typeof payload === 'string'
? payload
: payload && typeof payload === 'object' && 'message' in payload
? payload.message
: 'Invalid request.';
toast.warn(message);
});
Prompt to fix with AI

Copy this prompt into your AI coding assistant to fix this issue.

In server.js the FS_UPLOAD_BATCH invalid-nodes branch (line 205-208) fires both a socket.emit(ACTIONS.INVALID_PAYLOAD, …) and reply(false, …). In EditorPage.js both the new INVALID_PAYLOAD listener (line 245) and the FS_UPLOAD_BATCH ack callback (line 563-568) call toast.error, so the user sees two toasts. Fix by removing socket.emit(ACTIONS.INVALID_PAYLOAD, …) from the FS_UPLOAD_BATCH handler in server.js and relying solely on the ack reply to surface this error, keeping INVALID_PAYLOAD reserved for fire-and-forget events that have no ack channel.

// File system sync
socketRef.current.on(ACTIONS.FS_SYNC, ({ fileSystem: fs, fileContents }) => {
if (fileContents && typeof fileContents === 'object') {
Expand Down Expand Up @@ -304,6 +318,7 @@ const EditorPage = () => {
socketRef.current.off(ACTIONS.DENY_CODE_EDIT);
socketRef.current.off(ACTIONS.FS_SYNC);
socketRef.current.off(ACTIONS.PERMISSION_DENIED);
socketRef.current.off(ACTIONS.INVALID_PAYLOAD);
}
};
}, []);
Expand Down Expand Up @@ -346,6 +361,10 @@ const EditorPage = () => {
});
}, []);

// handleCodeChange is invoked by the Editor's debounced change handler.
// The ref is always updated immediately so downloads/runs get the latest
// content; the setState (which triggers a React re-render) arrives at the
// debounced cadence set in Editor.js (~300 ms), preventing render storms.
const handleCodeChange = useCallback((fileId, value) => {
fileContentsRef.current[fileId] = value;
setFileContentsSnapshot(prev => ({ ...prev, [fileId]: value }));
Expand Down
Loading