From b9947e1f1a9104c28b65160f0f993029abbda2ed Mon Sep 17 00:00:00 2001 From: adarshm11 Date: Sun, 29 Mar 2026 13:30:20 -0700 Subject: [PATCH 1/8] dropdown for other languages --- frontend/src/components/room/CodeEditor.tsx | 144 ++++++++++++++---- frontend/src/components/room/RoomPage.tsx | 11 +- .../components/templates/TemplatesPage.tsx | 2 +- frontend/src/util/defaultCode.ts | 4 + ...ple.ts => reactTemplateContent.example.ts} | 0 frontend/src/util/reactTemplateContent.ts | 124 +++++++++++++++ 6 files changed, 249 insertions(+), 36 deletions(-) create mode 100644 frontend/src/util/defaultCode.ts rename frontend/src/util/{templateContent.example.ts => reactTemplateContent.example.ts} (100%) create mode 100644 frontend/src/util/reactTemplateContent.ts diff --git a/frontend/src/components/room/CodeEditor.tsx b/frontend/src/components/room/CodeEditor.tsx index 86f3fb7..a7bb002 100644 --- a/frontend/src/components/room/CodeEditor.tsx +++ b/frontend/src/components/room/CodeEditor.tsx @@ -2,11 +2,27 @@ import Editor from '@monaco-editor/react'; import { useState, useRef, useContext, useEffect, useCallback } from 'react'; import { DarkModeContext, UserContext } from '../../App'; import { SandpackProvider, SandpackPreview } from '@codesandbox/sandpack-react'; +import { DEFAULT_CODE as DEFAULT_REACT_CODE } from '../../util/reactTemplateContent'; +import { DEFAULT_PYTHON_CODE, DEFAULT_JAVA_CODE, DEFAULT_CPP_CODE, DEFAULT_JAVASCRIPT_CODE } from '../../util/defaultCode'; + +type Language = 'react' | 'javascript' | 'python' | 'cpp' | 'java'; +export type InterviewType = 'react' | 'leetcode'; + +const LANGUAGE_OPTIONS: { value: Language; label: string; monacoLang: string; sandpackTemplate?: 'react'; defaultCode: string }[] = [ + { value: 'react', label: 'React', monacoLang: 'javascript', sandpackTemplate: 'react', defaultCode: DEFAULT_REACT_CODE }, + { value: 'javascript', label: 'JavaScript', monacoLang: 'javascript', defaultCode: DEFAULT_JAVASCRIPT_CODE }, + { value: 'python', label: 'Python', monacoLang: 'python', defaultCode: DEFAULT_PYTHON_CODE }, + { value: 'cpp', label: 'C++', monacoLang: 'cpp', defaultCode: DEFAULT_CPP_CODE }, + { value: 'java', label: 'Java', monacoLang: 'java', defaultCode: DEFAULT_JAVA_CODE }, +]; + +const LEETCODE_LANGUAGES: Language[] = ['javascript', 'python', 'cpp', 'java']; interface CodeEditorProps { code: string; setCode: (code: string) => void; ws: WebSocket | null; + interviewType: InterviewType; users: Array<{ userId: string; userName: string; @@ -23,9 +39,11 @@ interface CodeEditorProps { }>; } -function CodeEditor({ code, setCode, ws, users }: CodeEditorProps) { +function CodeEditor({ code, setCode, ws, interviewType, users }: CodeEditorProps) { const { isDark } = useContext(DarkModeContext); const { userId } = useContext(UserContext); + const [language, setLanguage] = useState(interviewType === 'leetcode' ? 'javascript' : 'react'); + const [langDropdownOpen, setLangDropdownOpen] = useState(false); const editorRef = useRef(null); const monacoRef = useRef(null); const wsRef = useRef(ws); @@ -347,13 +365,67 @@ function CodeEditor({ code, setCode, ws, users }: CodeEditorProps) { }; }, [users, userId, visibleLabels]); + const availableLanguages = interviewType === 'leetcode' + ? LANGUAGE_OPTIONS.filter(l => LEETCODE_LANGUAGES.includes(l.value)) + : LANGUAGE_OPTIONS.filter(l => l.value === 'react'); + const selectedLang = LANGUAGE_OPTIONS.find(l => l.value === language)!; + const showPreview = interviewType === 'react'; + return ( -
+
+ {/* Language dropdown — only shown for leetcode interviews */} + {interviewType === 'leetcode' &&
+
+ + {langDropdownOpen && ( +
+ {availableLanguages.map(opt => ( + + ))} +
+ )} +
+
} {isDragging &&
}
- {hasError && ( -
- +
+ )} + - Reload Preview - + + + + ) : ( +
+

Preview not available for {selectedLang.label}

)} - - -
); diff --git a/frontend/src/components/room/RoomPage.tsx b/frontend/src/components/room/RoomPage.tsx index c31e563..f60fc72 100644 --- a/frontend/src/components/room/RoomPage.tsx +++ b/frontend/src/components/room/RoomPage.tsx @@ -2,13 +2,17 @@ import { useParams, useNavigate } from 'react-router-dom'; import { useState, useEffect, useContext, useRef } from 'react'; import { joinRoom, getRoomName } from '../../api/api'; import EnterName from './EnterName'; -import CodeEditor from './CodeEditor'; +import CodeEditor, { type InterviewType } from './CodeEditor'; import Popup from '../popup/Popup'; import { DarkModeContext, UserContext } from '../../App'; -import { DEFAULT_CODE } from '../../util/templateContent'; +import { DEFAULT_CODE } from '../../util/reactTemplateContent'; const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:7778'; -function RoomPage() { +interface RoomPageProps { + interviewType?: InterviewType; +} + +function RoomPage({ interviewType = 'react' }: RoomPageProps) { const { roomId } = useParams<{ roomId: string }>(); const navigate = useNavigate(); const { isDark } = useContext(DarkModeContext); @@ -320,6 +324,7 @@ function RoomPage() { code={code} setCode={setCode} ws={ws} + interviewType={interviewType} users={users} /> {/* Toast notifications */} diff --git a/frontend/src/components/templates/TemplatesPage.tsx b/frontend/src/components/templates/TemplatesPage.tsx index ae4bb5d..5603b2f 100644 --- a/frontend/src/components/templates/TemplatesPage.tsx +++ b/frontend/src/components/templates/TemplatesPage.tsx @@ -2,7 +2,7 @@ import { useContext } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { DarkModeContext, UserContext } from '../../App'; import { createRoom } from '../../api/api'; -import { TEMPLATES, type Template } from '../../util/templateContent'; +import { TEMPLATES, type Template } from '../../util/reactTemplateContent'; function TemplatesPage() { const { isDark } = useContext(DarkModeContext); diff --git a/frontend/src/util/defaultCode.ts b/frontend/src/util/defaultCode.ts new file mode 100644 index 0000000..024d875 --- /dev/null +++ b/frontend/src/util/defaultCode.ts @@ -0,0 +1,4 @@ +export const DEFAULT_PYTHON_CODE = `def main():\n print("Hello, World!")` +export const DEFAULT_JAVA_CODE = `public class Main {\n public static void main(String[] args) {\n System.out.println("Hello, World!");\n }\n}` +export const DEFAULT_CPP_CODE = `#include \n\nint main() {\n std::cout << "Hello, World!" << std::endl;\n return 0;\n}` +export const DEFAULT_JAVASCRIPT_CODE = `function main() {\n console.log("Hello, World!");\n}` diff --git a/frontend/src/util/templateContent.example.ts b/frontend/src/util/reactTemplateContent.example.ts similarity index 100% rename from frontend/src/util/templateContent.example.ts rename to frontend/src/util/reactTemplateContent.example.ts diff --git a/frontend/src/util/reactTemplateContent.ts b/frontend/src/util/reactTemplateContent.ts new file mode 100644 index 0000000..22c8023 --- /dev/null +++ b/frontend/src/util/reactTemplateContent.ts @@ -0,0 +1,124 @@ +export const DEFAULT_CODE = `import React from 'react'; + +function App() { + return ( +
+

Hello, World!

+
+ ); +} +export default App;`; + +export interface Template { + name: string; + description: string; + code: string; +} + +export const TEMPLATES: Template[] = [ + { + name: 'standard', + description: 'start with a clean react component', + code: DEFAULT_CODE, + }, + { + name: 'gas station', + description: 'the thanos problem', + code: `/* +You are in SCE dev team and a member comes to you asking for help. +They are querying an API to get the information about a specific Costco +gas station, and their code doesn't work. Can you help them figure it out? +Definition of working: +1. The address, including the city is displayed +2. The regular and premium gas prices with a '$' is visible on the page +3. The full open hours for the entire week should be visible on the webpage. +*/ + +import { useState, useEffect } from "react"; + +function App() { + // State for Costco data + const [data, setData] = useState(''); + + // fetch Costco data on page load + useEffect(() => { + const fetchCostcoData = async () => { + const response = await fetch("https://sce.sjsu.edu/gas"); + const responseJsonData = await response.json(); + setData(responseJsonData); + }; + + fetchCostcoData(); + }, []); + + function renderCostcoData() { + {/* how the heck do i get the gas station hours?! */ } + for (const info of data.gasStationHours) { + console.log(info) + } + if (!data) { + return

Loading...

; + } + return ( +
+

{data.address + data.city}

+

Regular gas price: $ {data.regularGasPrice}

+

Premium gas price: $ {data.premiumGasPrice}

+

Gas Station Open Hours:

+
+          Gas station hours go here, assuming i can get the above loop working
+        
+
+ ); + } + + return ( + <> +
+

Welcome to the SCE Costco Monitoring Page!

+ {renderCostcoData()} +
+ + ); +} + +export default App;`, + }, + { + name: 'isbn problem', + description: 'the standard sce interview', + code: `/* +The SJSU website is down! Students need to find their textbook ISBNs. +Build a simple webpage with: +- An input box to enter an ISBN number +- A submit button +- When submitted, use the SCE ISBN API at https://sce.sjsu.edu/isbn/ to fetch and display: + - Book title + - Author + - Link to the OpenLibrary page + - Cover image +- If the ISBN isn't found, show a friendly "Book not found" message. + +example ISBN requests with json responses look like: + +https://sce.sjsu.edu/isbn/9780060244194 +https://sce.sjsu.edu/isbn/9780060254926 + +you can look for example ISBN numbers with the below search tool: +https://openlibrary.org/ +*/ + +import { useState } from 'react' + +function App() { + return ( + <> +

SCE ISBN Problem

+ + ) +} + +export default App;`, + } + // Add your interview templates here +]; From 452702f4bdc68f0ff3fa0e316cb2eba3f80fdb91 Mon Sep 17 00:00:00 2001 From: adarshm11 Date: Sun, 29 Mar 2026 13:45:22 -0700 Subject: [PATCH 2/8] templates for react or leetcode interview --- frontend/src/api/api.ts | 3 +- frontend/src/components/home/HomePage.tsx | 209 ++++++++++++------ frontend/src/components/room/RoomPage.tsx | 6 +- .../components/templates/TemplatesPage.tsx | 2 +- handlers/http.go | 2 +- models/http.go | 1 + models/room.go | 4 +- services/room.go | 4 +- utils/defaultCode.go | 28 ++- 9 files changed, 185 insertions(+), 74 deletions(-) diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index aa5e896..2c31021 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -1,6 +1,6 @@ const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:7778'; -export async function createRoom(userId: string, name: string, roomName: string, initialCode?: string) { +export async function createRoom(userId: string, name: string, roomName: string, language: string, initialCode?: string) { try { const response = await fetch(`${API_URL}/createRoom`, { method: 'POST', @@ -11,6 +11,7 @@ export async function createRoom(userId: string, name: string, roomName: string, userId, name, roomName, + language, ...(initialCode !== undefined && { initialCode }), }), }); diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index b5d2c12..d13b9eb 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -1,53 +1,76 @@ import React, { useState, useContext, useEffect } from 'react'; import { createRoom, validateApiKey } from '../../api/api'; import { useNavigate } from 'react-router-dom'; -import { DarkModeContext } from '../../App'; -import { UserContext } from '../../App'; +import { DarkModeContext, UserContext } from '../../App'; import Popup from '../popup/Popup'; +import { TEMPLATES, DEFAULT_CODE as DEFAULT_REACT_CODE } from '../../util/reactTemplateContent'; +import { DEFAULT_JAVASCRIPT_CODE } from '../../util/defaultCode'; +import { type InterviewType } from '../room/CodeEditor'; + +interface TemplateCard { + name: string; + description: string; + code: string; + interviewType: InterviewType; +} + +const REACT_CARD: TemplateCard = { + name: 'react interview', + description: 'build and run a react component in real time', + code: DEFAULT_REACT_CODE, + interviewType: 'react', +}; + +const LEETCODE_CARD: TemplateCard = { + name: 'leetcode interview', + description: 'write code in python, c++, java, or javascript', + code: DEFAULT_JAVASCRIPT_CODE, + interviewType: 'leetcode', +}; + +// Non-standard templates require an API key — exclude 'standard' since REACT_CARD already covers it +const EXTRA_TEMPLATES: TemplateCard[] = TEMPLATES + .filter(t => t.name !== 'standard') + .map(t => ({ ...t, interviewType: 'react' as InterviewType })); function HomePage() { const { isDark } = useContext(DarkModeContext); const [userName, setUserName] = useState(''); const [roomName, setRoomName] = useState(''); const [showPopup, setShowPopup] = useState(false); + const [showTemplateModal, setShowTemplateModal] = useState(false); const [showApiKeyModal, setShowApiKeyModal] = useState(false); const [apiKeyInput, setApiKeyInput] = useState(''); const [apiKeyError, setApiKeyError] = useState(''); const [isValidating, setIsValidating] = useState(false); + const [isAuthenticated, setIsAuthenticated] = useState(false); const navigate = useNavigate(); const { userId } = useContext(UserContext); - const handleCreateRoom = async () => { - let response; - if (roomName === '') { - response = await createRoom(userId, userName, `${userName}'s sce interview`); - } else { - response = await createRoom(userId, userName, roomName); - } - if (response.ok) { - const roomId = response.data.roomId; - const expiry = new Date().getTime() + (24 * 60 * 60 * 1000); // 24 hours - const data = JSON.stringify({ userName, expiry }); - localStorage.setItem(`goderpad-cookie-${roomId}`, data); - navigate(`/${roomId}`); - } else { - setShowPopup(true); - } - }; - useEffect(() => { - if (!showApiKeyModal) return; const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') setShowApiKeyModal(false); + if (e.key === 'Escape') { + if (showApiKeyModal) setShowApiKeyModal(false); + else if (showTemplateModal) setShowTemplateModal(false); + } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [showApiKeyModal]); + }, [showApiKeyModal, showTemplateModal]); - const handleTemplateButtonClick = () => { - setApiKeyInput(''); - setApiKeyError(''); - setShowApiKeyModal(true); + const handleSelectTemplate = async (template: TemplateCard) => { + const finalRoomName = roomName || template.name; + const language = template.interviewType === 'leetcode' ? 'javascript' : 'react'; + const response = await createRoom(userId, userName, finalRoomName, language, template.code); + if (response.ok) { + const roomId = response.data.roomId; + const expiry = new Date().getTime() + (24 * 60 * 60 * 1000); + localStorage.setItem(`goderpad-cookie-${roomId}`, JSON.stringify({ userName, expiry })); + navigate(`/${roomId}`, { state: { interviewType: template.interviewType } }); + } else { + setShowTemplateModal(false); + setShowPopup(true); + } }; const handleApiKeySubmit = async (e: React.FormEvent) => { @@ -62,19 +85,25 @@ function HomePage() { return; } sessionStorage.setItem('api_key', apiKeyInput); - navigate('/templates', { state: { userName, roomName, userId } }); + setIsAuthenticated(true); + setShowApiKeyModal(false); }; + const templates: TemplateCard[] = [ + REACT_CARD, + LEETCODE_CARD, + ...(isAuthenticated ? EXTRA_TEMPLATES : []), + ]; + return (<> { - setShowPopup(false); - }} + onClickButton={() => setShowPopup(false)} /> -
+ +

welcome to goderpad

sce's interview platform

@@ -88,16 +117,12 @@ function HomePage() { type='text' value={userName} onChange={(e) => setUserName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && userName.trim()) { - handleCreateRoom(); - } - }} + onKeyDown={(e) => { if (e.key === 'Enter' && userName.trim()) setShowTemplateModal(true); }} placeholder='enter your name' className={`px-5 py-4 text-lg rounded-lg focus:outline-none focus:border-blue-500 ${isDark - ? 'bg-slate-800 border border-slate-700 text-white' - : 'bg-white border border-gray-300 text-gray-900' - }`} + ? 'bg-slate-800 border border-slate-700 text-white' + : 'bg-white border border-gray-300 text-gray-900' + }`} />
@@ -110,64 +135,120 @@ function HomePage() { type='text' value={roomName} onChange={(e) => setRoomName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && userName.trim()) { - handleCreateRoom(); - } - }} + onKeyDown={(e) => { if (e.key === 'Enter' && userName.trim()) setShowTemplateModal(true); }} placeholder='name your room' className={`px-5 py-4 text-lg rounded-lg focus:outline-none focus:border-blue-500 ${isDark - ? 'bg-slate-800 border border-slate-700 text-white' - : 'bg-white border border-gray-300 text-gray-900' - }`} + ? 'bg-slate-800 border border-slate-700 text-white' + : 'bg-white border border-gray-300 text-gray-900' + }`} />
- -
+ {/* Template selection modal */} + {showTemplateModal && ( +
setShowTemplateModal(false)} + > +
e.stopPropagation()} + className={`relative rounded-2xl shadow-2xl p-8 w-full max-w-3xl mx-4 max-h-[85vh] overflow-y-auto ${isDark ? 'bg-slate-800' : 'bg-white'}`} + > +
+

choose a template

+ +
+ +
2 ? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3' : 'grid-cols-1 sm:grid-cols-2'}`}> + {templates.map((template) => ( + + ))} +
+ + {!isAuthenticated && ( +
+ +
+ )} +
+
+ )} + + {/* API key modal */} {showApiKeyModal && (
setShowApiKeyModal(false)}>
e.stopPropagation()} className={`rounded-xl shadow-2xl p-8 max-w-sm w-full mx-4 ${isDark ? 'bg-slate-800' : 'bg-white'}`}>

Enter API Key

An API key is required to access interview templates.

{ setApiKeyInput(e.target.value); setApiKeyError(''); }} - placeholder="API key" + placeholder='API key' className={`w-full px-4 py-2 rounded-lg border outline-none mb-2 ${isDark ? 'bg-slate-700 border-slate-600 text-white placeholder-gray-400' : 'bg-gray-50 border-gray-300 text-gray-900'}`} autoFocus /> -

{apiKeyError}

-
+

{apiKeyError}

+
diff --git a/frontend/src/components/room/RoomPage.tsx b/frontend/src/components/room/RoomPage.tsx index f60fc72..0652ea7 100644 --- a/frontend/src/components/room/RoomPage.tsx +++ b/frontend/src/components/room/RoomPage.tsx @@ -1,4 +1,4 @@ -import { useParams, useNavigate } from 'react-router-dom'; +import { useParams, useNavigate, useLocation } from 'react-router-dom'; import { useState, useEffect, useContext, useRef } from 'react'; import { joinRoom, getRoomName } from '../../api/api'; import EnterName from './EnterName'; @@ -12,9 +12,11 @@ interface RoomPageProps { interviewType?: InterviewType; } -function RoomPage({ interviewType = 'react' }: RoomPageProps) { +function RoomPage({ interviewType: propInterviewType }: RoomPageProps) { const { roomId } = useParams<{ roomId: string }>(); const navigate = useNavigate(); + const location = useLocation(); + const interviewType: InterviewType = (location.state as { interviewType?: InterviewType })?.interviewType ?? propInterviewType ?? 'react'; const { isDark } = useContext(DarkModeContext); const { userId } = useContext(UserContext); const [userName, setUserName] = useState(''); diff --git a/frontend/src/components/templates/TemplatesPage.tsx b/frontend/src/components/templates/TemplatesPage.tsx index 5603b2f..a0a622c 100644 --- a/frontend/src/components/templates/TemplatesPage.tsx +++ b/frontend/src/components/templates/TemplatesPage.tsx @@ -18,7 +18,7 @@ function TemplatesPage() { const handleSelectTemplate = async (template: Template) => { const finalRoomName = roomName || template.name; - const response = await createRoom(userId, userName, finalRoomName, template.code); + const response = await createRoom(userId, userName, finalRoomName, 'react', template.code); if (response.ok) { const roomId = response.data.roomId; const expiry = new Date().getTime() + (24 * 60 * 60 * 1000); diff --git a/handlers/http.go b/handlers/http.go index 2cff1db..06f34a0 100644 --- a/handlers/http.go +++ b/handlers/http.go @@ -19,7 +19,7 @@ func CreateRoomHandler(c *gin.Context) { return } - roomID, err := services.CreateRoom(req.UserID, req.Name, req.RoomName, req.InitialCode) + roomID, err := services.CreateRoom(req.UserID, req.Name, req.RoomName, req.Language, req.InitialCode) if err != nil { if errors.Is(err, models.ErrRoomExists) || errors.Is(err, models.ErrRoomNil) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) diff --git a/models/http.go b/models/http.go index 7595875..9ff0623 100644 --- a/models/http.go +++ b/models/http.go @@ -4,6 +4,7 @@ type CreateRoomRequest struct { UserID string `json:"userId"` Name string `json:"name"` RoomName string `json:"roomName"` + Language string `json:"language"` InitialCode string `json:"initialCode"` } diff --git a/models/room.go b/models/room.go index 1ee5d46..ad90760 100644 --- a/models/room.go +++ b/models/room.go @@ -26,8 +26,8 @@ type Room struct { saveDebounce *time.Timer `json:"-"` } -func NewRoom(roomID, roomName, initialCode string) *Room { - document := utils.DEFAULT_CODE +func NewRoom(roomID, roomName, language, initialCode string) *Room { + document := utils.DefaultCodeForLanguage(language) if initialCode != "" { document = initialCode } diff --git a/services/room.go b/services/room.go index d175652..6cf7755 100644 --- a/services/room.go +++ b/services/room.go @@ -14,9 +14,9 @@ import ( // CreateRoom creates a new room with the given details. // It does NOT add a user to the room, that is handled in the JoinRoom function. -func CreateRoom(userID, name, roomName, initialCode string) (string, error) { +func CreateRoom(userID, name, roomName, language, initialCode string) (string, error) { roomID := utils.GenerateRoomCode() - room := models.NewRoom(roomID, roomName, initialCode) + room := models.NewRoom(roomID, roomName, language, initialCode) hub := models.GetHub() if err := hub.AddRoom(room); err != nil { diff --git a/utils/defaultCode.go b/utils/defaultCode.go index 3c2974c..7b8a735 100644 --- a/utils/defaultCode.go +++ b/utils/defaultCode.go @@ -1,3 +1,29 @@ package utils -const DEFAULT_CODE = "import React from 'react';\n\nfunction App() {\n return (\n
\n

Hello, World!

\n
\n );\n}\nexport default App;" +const DEFAULT_REACT_CODE = "import React from 'react';\n\nfunction App() {\n return (\n
\n

Hello, World!

\n
\n );\n}\nexport default App;" + +const DEFAULT_PYTHON_CODE = "def main():\n print(\"Hello, World!\")" + +const DEFAULT_JAVA_CODE = "public class Main {\n public static void main(String[] args) {\n System.out.println(\"Hello, World!\");\n }\n}" + +const DEFAULT_CPP_CODE = "#include \n\nint main() {\n std::cout << \"Hello, World!\" << std::endl;\n return 0;\n}" + +const DEFAULT_JAVASCRIPT_CODE = "function main() {\n console.log(\"Hello, World!\");\n}" + +// DEFAULT_CODE kept for backwards compatibility +const DEFAULT_CODE = DEFAULT_REACT_CODE + +func DefaultCodeForLanguage(language string) string { + switch language { + case "python": + return DEFAULT_PYTHON_CODE + case "java": + return DEFAULT_JAVA_CODE + case "cpp": + return DEFAULT_CPP_CODE + case "javascript": + return DEFAULT_JAVASCRIPT_CODE + default: // "react" and anything unrecognised + return DEFAULT_REACT_CODE + } +} From c524451573da12222a1ae3bfe6c5be42ca7c7cdf Mon Sep 17 00:00:00 2001 From: adarshm11 Date: Sun, 29 Mar 2026 21:26:23 -0700 Subject: [PATCH 3/8] gatekeep --- .gitignore | 2 +- frontend/src/util/reactTemplateContent.ts | 124 ---------------------- 2 files changed, 1 insertion(+), 125 deletions(-) delete mode 100644 frontend/src/util/reactTemplateContent.ts diff --git a/.gitignore b/.gitignore index f4b95e2..7eeb1d2 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,4 @@ config.yaml past/ # Private interview template content -frontend/src/util/templateContent.ts \ No newline at end of file +frontend/src/util/reactTemplateContent.ts \ No newline at end of file diff --git a/frontend/src/util/reactTemplateContent.ts b/frontend/src/util/reactTemplateContent.ts deleted file mode 100644 index 22c8023..0000000 --- a/frontend/src/util/reactTemplateContent.ts +++ /dev/null @@ -1,124 +0,0 @@ -export const DEFAULT_CODE = `import React from 'react'; - -function App() { - return ( -
-

Hello, World!

-
- ); -} -export default App;`; - -export interface Template { - name: string; - description: string; - code: string; -} - -export const TEMPLATES: Template[] = [ - { - name: 'standard', - description: 'start with a clean react component', - code: DEFAULT_CODE, - }, - { - name: 'gas station', - description: 'the thanos problem', - code: `/* -You are in SCE dev team and a member comes to you asking for help. -They are querying an API to get the information about a specific Costco -gas station, and their code doesn't work. Can you help them figure it out? -Definition of working: -1. The address, including the city is displayed -2. The regular and premium gas prices with a '$' is visible on the page -3. The full open hours for the entire week should be visible on the webpage. -*/ - -import { useState, useEffect } from "react"; - -function App() { - // State for Costco data - const [data, setData] = useState(''); - - // fetch Costco data on page load - useEffect(() => { - const fetchCostcoData = async () => { - const response = await fetch("https://sce.sjsu.edu/gas"); - const responseJsonData = await response.json(); - setData(responseJsonData); - }; - - fetchCostcoData(); - }, []); - - function renderCostcoData() { - {/* how the heck do i get the gas station hours?! */ } - for (const info of data.gasStationHours) { - console.log(info) - } - if (!data) { - return

Loading...

; - } - return ( -
-

{data.address + data.city}

-

Regular gas price: $ {data.regularGasPrice}

-

Premium gas price: $ {data.premiumGasPrice}

-

Gas Station Open Hours:

-
-          Gas station hours go here, assuming i can get the above loop working
-        
-
- ); - } - - return ( - <> -
-

Welcome to the SCE Costco Monitoring Page!

- {renderCostcoData()} -
- - ); -} - -export default App;`, - }, - { - name: 'isbn problem', - description: 'the standard sce interview', - code: `/* -The SJSU website is down! Students need to find their textbook ISBNs. -Build a simple webpage with: -- An input box to enter an ISBN number -- A submit button -- When submitted, use the SCE ISBN API at https://sce.sjsu.edu/isbn/ to fetch and display: - - Book title - - Author - - Link to the OpenLibrary page - - Cover image -- If the ISBN isn't found, show a friendly "Book not found" message. - -example ISBN requests with json responses look like: - -https://sce.sjsu.edu/isbn/9780060244194 -https://sce.sjsu.edu/isbn/9780060254926 - -you can look for example ISBN numbers with the below search tool: -https://openlibrary.org/ -*/ - -import { useState } from 'react' - -function App() { - return ( - <> -

SCE ISBN Problem

- - ) -} - -export default App;`, - } - // Add your interview templates here -]; From 8caf987c0648ec88b7db3dad8560ff5ac320f10c Mon Sep 17 00:00:00 2001 From: adarshm11 Date: Sun, 29 Mar 2026 21:53:08 -0700 Subject: [PATCH 4/8] save code across language switches --- cmd/server/main.go | 1 + frontend/src/api/api.ts | 18 ++++++++++ frontend/src/components/home/HomePage.tsx | 3 +- frontend/src/components/room/CodeEditor.tsx | 38 ++++++++++++++------- frontend/src/components/room/RoomPage.tsx | 1 + frontend/src/util/defaultCode.ts | 3 +- handlers/http.go | 27 +++++++++++++++ models/http.go | 5 +++ models/room.go | 29 +++++++++++++--- services/room.go | 13 ++++++- 10 files changed, 117 insertions(+), 21 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index b89cef6..52371e7 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -38,6 +38,7 @@ func main() { r.POST("/createRoom", handlers.CreateRoomHandler) r.POST("/joinRoom", handlers.JoinRoomHandler) + r.POST("/switchLanguage", handlers.SwitchLanguageHandler) r.GET("/getRoomName/:roomID", handlers.GetRoomNameHandler) r.GET("/past/:roomID", handlers.GetDocumentSaveHandler) r.GET("/validateKey", handlers.ValidateKeyHandler) diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index 2c31021..aeed301 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -46,6 +46,24 @@ export async function joinRoom(userId: string, name: string, roomId: string) { } } +export async function switchLanguage(roomId: string, language: string) { + try { + const response = await fetch(`${API_URL}/switchLanguage`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ roomId, language }), + }); + return await response.json(); + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : 'Error switching language' + }; + } +} + export async function getRoomName(roomId: string) { try { const response = await fetch(`${API_URL}/getRoomName/${roomId}`); diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index d13b9eb..4cafae0 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -4,7 +4,6 @@ import { useNavigate } from 'react-router-dom'; import { DarkModeContext, UserContext } from '../../App'; import Popup from '../popup/Popup'; import { TEMPLATES, DEFAULT_CODE as DEFAULT_REACT_CODE } from '../../util/reactTemplateContent'; -import { DEFAULT_JAVASCRIPT_CODE } from '../../util/defaultCode'; import { type InterviewType } from '../room/CodeEditor'; interface TemplateCard { @@ -24,7 +23,7 @@ const REACT_CARD: TemplateCard = { const LEETCODE_CARD: TemplateCard = { name: 'leetcode interview', description: 'write code in python, c++, java, or javascript', - code: DEFAULT_JAVASCRIPT_CODE, + code: `function main() {\n console.log("Hello, World!");\n}`, interviewType: 'leetcode', }; diff --git a/frontend/src/components/room/CodeEditor.tsx b/frontend/src/components/room/CodeEditor.tsx index a7bb002..db39d90 100644 --- a/frontend/src/components/room/CodeEditor.tsx +++ b/frontend/src/components/room/CodeEditor.tsx @@ -2,26 +2,26 @@ import Editor from '@monaco-editor/react'; import { useState, useRef, useContext, useEffect, useCallback } from 'react'; import { DarkModeContext, UserContext } from '../../App'; import { SandpackProvider, SandpackPreview } from '@codesandbox/sandpack-react'; -import { DEFAULT_CODE as DEFAULT_REACT_CODE } from '../../util/reactTemplateContent'; -import { DEFAULT_PYTHON_CODE, DEFAULT_JAVA_CODE, DEFAULT_CPP_CODE, DEFAULT_JAVASCRIPT_CODE } from '../../util/defaultCode'; +import { switchLanguage } from '../../api/api'; type Language = 'react' | 'javascript' | 'python' | 'cpp' | 'java'; export type InterviewType = 'react' | 'leetcode'; -const LANGUAGE_OPTIONS: { value: Language; label: string; monacoLang: string; sandpackTemplate?: 'react'; defaultCode: string }[] = [ - { value: 'react', label: 'React', monacoLang: 'javascript', sandpackTemplate: 'react', defaultCode: DEFAULT_REACT_CODE }, - { value: 'javascript', label: 'JavaScript', monacoLang: 'javascript', defaultCode: DEFAULT_JAVASCRIPT_CODE }, - { value: 'python', label: 'Python', monacoLang: 'python', defaultCode: DEFAULT_PYTHON_CODE }, - { value: 'cpp', label: 'C++', monacoLang: 'cpp', defaultCode: DEFAULT_CPP_CODE }, - { value: 'java', label: 'Java', monacoLang: 'java', defaultCode: DEFAULT_JAVA_CODE }, +const LANGUAGE_OPTIONS: { value: Language; label: string; monacoLang: string; sandpackTemplate?: 'react' }[] = [ + { value: 'react', label: 'React', monacoLang: 'javascript', sandpackTemplate: 'react' }, + { value: 'javascript', label: 'JavaScript', monacoLang: 'javascript' }, + { value: 'python', label: 'Python', monacoLang: 'python' }, + { value: 'cpp', label: 'C++', monacoLang: 'cpp' }, + { value: 'java', label: 'Java', monacoLang: 'java' }, ]; -const LEETCODE_LANGUAGES: Language[] = ['javascript', 'python', 'cpp', 'java']; +const LEETCODE_LANGUAGES: Language[] = ['python', 'cpp', 'java', 'javascript']; interface CodeEditorProps { code: string; setCode: (code: string) => void; ws: WebSocket | null; + roomId: string; interviewType: InterviewType; users: Array<{ userId: string; @@ -39,7 +39,7 @@ interface CodeEditorProps { }>; } -function CodeEditor({ code, setCode, ws, interviewType, users }: CodeEditorProps) { +function CodeEditor({ code, setCode, ws, roomId, interviewType, users }: CodeEditorProps) { const { isDark } = useContext(DarkModeContext); const { userId } = useContext(UserContext); const [language, setLanguage] = useState(interviewType === 'leetcode' ? 'javascript' : 'react'); @@ -366,7 +366,7 @@ function CodeEditor({ code, setCode, ws, interviewType, users }: CodeEditorProps }, [users, userId, visibleLabels]); const availableLanguages = interviewType === 'leetcode' - ? LANGUAGE_OPTIONS.filter(l => LEETCODE_LANGUAGES.includes(l.value)) + ? LEETCODE_LANGUAGES.map(lang => LANGUAGE_OPTIONS.find(l => l.value === lang)!) : LANGUAGE_OPTIONS.filter(l => l.value === 'react'); const selectedLang = LANGUAGE_OPTIONS.find(l => l.value === language)!; const showPreview = interviewType === 'react'; @@ -407,7 +407,21 @@ function CodeEditor({ code, setCode, ws, interviewType, users }: CodeEditorProps {availableLanguages.map(opt => ( +
+
+ {runError && ( +

{runError}

+ )} + {runOutput && ( + <> + {runOutput.stdout && ( +
{runOutput.stdout}
+ )} + {runOutput.stderr && ( +
{runOutput.stderr}
+ )} +

+ exited with code {runOutput.code} +

+ + )} + {!runOutput && !runError && !isRunning && ( +

press Run to execute your code

+ )} +
)}
diff --git a/handlers/execute.go b/handlers/execute.go new file mode 100644 index 0000000..8007b53 --- /dev/null +++ b/handlers/execute.go @@ -0,0 +1,169 @@ +package handlers + +import ( + "bytes" + "context" + "fmt" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/gin-gonic/gin" + + "goderpad/config" +) + +const executionTimeout = 10 * time.Second + +type langConfig struct { + image string + dockerfile string + filename string + command string +} + +var languageConfigs = map[string]langConfig{ + "python": { + image: "goderpad-python", + dockerfile: "docker/python.Dockerfile", + filename: "main.py", + command: "python3 /code/main.py", + }, + "javascript": { + image: "goderpad-javascript", + dockerfile: "docker/javascript.Dockerfile", + filename: "main.js", + command: "node /code/main.js", + }, + "c++": { + image: "goderpad-cpp", + dockerfile: "docker/cpp.Dockerfile", + filename: "main.cpp", + command: "g++ -o /tmp/main /code/main.cpp && /tmp/main", + }, + "java": { + image: "goderpad-java", + dockerfile: "docker/java.Dockerfile", + filename: "Main.java", + command: "javac -d /tmp /code/Main.java && java -cp /tmp Main", + }, +} + +// BuildExecutionImages builds each language sandbox image from its Dockerfile. +// Run as a goroutine at startup — the /execute endpoint will return an error +// for any language whose image hasn't finished building yet. +func BuildExecutionImages() { + for lang, cfg := range languageConfigs { + log.Printf("building execution image for %s...", lang) + cmd := exec.Command(config.GetDockerBinaryPath(), "build", "-t", cfg.image, "-f", cfg.dockerfile, ".") + if out, err := cmd.CombinedOutput(); err != nil { + log.Printf("error building %s image: %v\n%s", lang, err, out) + } else { + log.Printf("built execution image for %s", lang) + } + } +} + +type executeRequest struct { + Language string `json:"language"` + Code string `json:"code"` +} + +type executeResult struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + Code int `json:"code"` +} + +func ExecuteHandler(c *gin.Context) { + if !config.GetEnableExecutionImages() { + c.JSON(http.StatusOK, executeResult{ + Stdout: "", + Stderr: "code execution is disabled! to enable, set enable_execution_images: true in config/config.yml and restart the server.", + Code: -1, + }) + return + } + + var req executeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "invalid request"}) + return + } + + result, err := runCode(req.Language, req.Code) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} + +func runCode(language, code string) (*executeResult, error) { + cfg, ok := languageConfigs[language] + if !ok { + return nil, fmt.Errorf("unsupported language: %s", language) + } + + dir, err := os.MkdirTemp("", "goderpad-*") + if err != nil { + return nil, fmt.Errorf("failed to create temp directory") + } + defer os.RemoveAll(dir) + + if err := os.WriteFile(filepath.Join(dir, cfg.filename), []byte(code), 0644); err != nil { + return nil, fmt.Errorf("failed to write code") + } + + ctx, cancel := context.WithTimeout(context.Background(), executionTimeout) + defer cancel() + + args := []string{ + "run", "--rm", + "--network=none", + "--memory=256m", + "--memory-swap=256m", + "--cpus=0.25", + "--pids-limit=64", + "--read-only", + "--tmpfs=/tmp:exec,size=32m", + "-v", dir + ":/code:ro", + cfg.image, + "sh", "-c", cfg.command, + } + + cmd := exec.CommandContext(ctx, config.GetDockerBinaryPath(), args...) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err = cmd.Run() + + if ctx.Err() == context.DeadlineExceeded { + return &executeResult{ + Stdout: stdout.String(), + Stderr: "execution timed out (10s limit)", + Code: -1, + }, nil + } + + exitCode := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + return nil, fmt.Errorf("docker unavailable — is it installed and running? (%v)", err) + } + } + + return &executeResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + Code: exitCode, + }, nil +} From dd84586b9ca511d9c5142d608b6548de7e214a44 Mon Sep 17 00:00:00 2001 From: adarshm11 Date: Mon, 30 Mar 2026 22:08:38 -0700 Subject: [PATCH 7/8] [ngmi] redis + containers for running code --- Dockerfile.runner | 26 ++++ cmd/runner/main.go | 145 ++++++++++++++++++++ cmd/server/main.go | 17 ++- config/config.example.yml | 7 +- config/config.go | 25 ++++ docker-compose.dev.yml | 24 +++- docker-compose.yml | 20 +++ execution/execution.go | 126 +++++++++++++++++ frontend/src/components/home/HomePage.tsx | 4 +- frontend/src/components/room/CodeEditor.tsx | 49 +++++-- go.mod | 5 +- go.sum | 8 ++ handlers/execute.go | 135 +----------------- handlers/ws.go | 29 ++++ models/job.go | 18 +++ models/ws.go | 2 + redisclient/client.go | 38 +++++ services/jobqueue.go | 128 +++++++++++++++++ utils/defaultCode.go | 4 +- 19 files changed, 656 insertions(+), 154 deletions(-) create mode 100644 Dockerfile.runner create mode 100644 cmd/runner/main.go create mode 100644 execution/execution.go create mode 100644 models/job.go create mode 100644 redisclient/client.go create mode 100644 services/jobqueue.go diff --git a/Dockerfile.runner b/Dockerfile.runner new file mode 100644 index 0000000..6437d46 --- /dev/null +++ b/Dockerfile.runner @@ -0,0 +1,26 @@ +# Build stage +FROM golang:1.25-alpine AS build + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o runner ./cmd/runner + +# Production stage +FROM alpine:latest + +RUN apk --no-cache add ca-certificates docker-cli && \ + ln -s /usr/bin/docker /usr/local/bin/docker + +WORKDIR /app + +COPY --from=build /app/runner . +COPY --from=build /app/config ./config +COPY --from=build /app/docker ./docker + +EXPOSE 0 + +CMD ["./runner"] diff --git a/cmd/runner/main.go b/cmd/runner/main.go new file mode 100644 index 0000000..cebf1b6 --- /dev/null +++ b/cmd/runner/main.go @@ -0,0 +1,145 @@ +package main + +import ( + "context" + "encoding/json" + "log" + "os" + "os/signal" + "syscall" + "time" + + "github.com/redis/go-redis/v9" + + "goderpad/config" + "goderpad/execution" + "goderpad/models" + "goderpad/redisclient" + "goderpad/services" +) + +const runnerGroup = "runners" + +func runnerConsumerName() string { + hostname, _ := os.Hostname() + return "runner-" + hostname +} + +func main() { + if err := config.Load("config/config.yml"); err != nil { + log.Fatalf("failed to load config: %v", err) + } + + if err := redisclient.Init(); err != nil { + log.Fatalf("failed to connect to redis: %v", err) + } + defer redisclient.Close() + + // Build sandbox Docker images before accepting jobs + if config.GetEnableExecutionImages() { + execution.BuildImages() + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + rdb := redisclient.GetClient() + consumer := runnerConsumerName() + + // Create consumer group for the jobs stream + err := rdb.XGroupCreateMkStream(ctx, services.JobsStream, runnerGroup, "0").Err() + if err != nil && err.Error() != "BUSYGROUP Consumer Group name already exists" { + log.Printf("warning: could not create jobs consumer group: %v", err) + } + + log.Printf("code runner started (consumer=%s), waiting for jobs...", consumer) + + for { + select { + case <-ctx.Done(): + log.Println("runner shutting down") + return + default: + } + + streams, err := rdb.XReadGroup(ctx, &redis.XReadGroupArgs{ + Group: runnerGroup, + Consumer: consumer, + Streams: []string{services.JobsStream, ">"}, + Count: 1, + Block: 5 * time.Second, + }).Result() + + if err != nil { + if err == redis.Nil || ctx.Err() != nil { + continue + } + log.Printf("error reading jobs stream: %v", err) + time.Sleep(1 * time.Second) + continue + } + + for _, stream := range streams { + for _, msg := range stream.Messages { + processJob(ctx, rdb, msg) + } + } + } +} + +func processJob(ctx context.Context, rdb *redis.Client, msg redis.XMessage) { + data, ok := msg.Values["data"].(string) + if !ok { + log.Printf("invalid job message: %s", msg.ID) + rdb.XAck(ctx, services.JobsStream, runnerGroup, msg.ID) + return + } + + var job models.ExecuteJob + if err := json.Unmarshal([]byte(data), &job); err != nil { + log.Printf("failed to unmarshal job: %v", err) + rdb.XAck(ctx, services.JobsStream, runnerGroup, msg.ID) + return + } + + log.Printf("executing job %s: %s for room %s", job.JobID, job.Language, job.RoomID) + + result := models.ExecuteResult{ + JobID: job.JobID, + RoomID: job.RoomID, + UserID: job.UserID, + } + + execResult, err := execution.RunCode(job.Language, job.Code) + if err != nil { + result.Stdout = "" + result.Stderr = err.Error() + result.Code = -1 + } else { + result.Stdout = execResult.Stdout + result.Stderr = execResult.Stderr + result.Code = execResult.Code + } + + // Publish result back to Redis + resultData, err := json.Marshal(result) + if err != nil { + log.Printf("failed to marshal result for job %s: %v", job.JobID, err) + rdb.XAck(ctx, services.JobsStream, runnerGroup, msg.ID) + return + } + + if err := rdb.XAdd(ctx, &redis.XAddArgs{ + Stream: services.ResultsStream, + MaxLen: 1000, + Approx: true, + Values: map[string]interface{}{ + "data": string(resultData), + }, + }).Err(); err != nil { + log.Printf("failed to publish result for job %s: %v", job.JobID, err) + } + + rdb.XAck(ctx, services.JobsStream, runnerGroup, msg.ID) + log.Printf("completed job %s (exit code: %d)", job.JobID, result.Code) +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 84c0357..5e883cb 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,16 +1,21 @@ package main import ( + "context" "log" + "os/signal" "strconv" + "syscall" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/prometheus/client_golang/prometheus/promhttp" "goderpad/config" + "goderpad/execution" "goderpad/handlers" "goderpad/metrics" + "goderpad/redisclient" "goderpad/services" ) @@ -50,9 +55,19 @@ func main() { go services.DeleteRoomSaves() if config.GetEnableExecutionImages() { - go handlers.BuildExecutionImages() + go execution.BuildImages() } + // Initialize Redis and start result listener + if err := redisclient.Init(); err != nil { + log.Fatalf("Failed to connect to Redis: %v", err) + } + defer redisclient.Close() + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + go services.StartResultListener(ctx) + r.Run(":" + config.GetPort()) } diff --git a/config/config.example.yml b/config/config.example.yml index 1c84091..6bb97a4 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -5,4 +5,9 @@ server: docker_binary_path: "" # leave empty to use "docker" from $PATH, or set full path e.g. /usr/local/bin/docker allowed_origins: - "http://localhost:7777" - - "http://localhost:5173" \ No newline at end of file + - "http://localhost:5173" + +redis: + addr: "localhost:6379" + password: "" + db: 0 \ No newline at end of file diff --git a/config/config.go b/config/config.go index 4efc7a7..abea2a6 100644 --- a/config/config.go +++ b/config/config.go @@ -9,6 +9,13 @@ import ( type Config struct { Server ServerConfig `yaml:"server"` + Redis RedisConfig `yaml:"redis"` +} + +type RedisConfig struct { + Addr string `yaml:"addr"` + Password string `yaml:"password"` + DB int `yaml:"db"` } type ServerConfig struct { @@ -52,6 +59,24 @@ func GetEnableExecutionImages() bool { return AppConfig.Server.EnableExecutionImages } +func GetRedisAddr() string { + if env := os.Getenv("REDIS_ADDR"); env != "" { + return env + } + if AppConfig.Redis.Addr != "" { + return AppConfig.Redis.Addr + } + return "localhost:6379" +} + +func GetRedisPassword() string { + return AppConfig.Redis.Password +} + +func GetRedisDB() int { + return AppConfig.Redis.DB +} + func GetDockerBinaryPath() string { if AppConfig.Server.DockerBinaryPath != "" { return AppConfig.Server.DockerBinaryPath diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 36b42b4..8b00b5e 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,4 +1,10 @@ services: + goderpad-redis: + image: redis:7-alpine + container_name: goderpad-redis + ports: + - "6379:6379" + goderpad-backend: build: context: . @@ -6,8 +12,24 @@ services: container_name: goderpad-backend ports: - "7778:7778" + environment: + - REDIS_ADDR=goderpad-redis:6379 volumes: - ./past:/app/past + depends_on: + - goderpad-redis + + goderpad-runner: + build: + context: . + dockerfile: Dockerfile.runner + container_name: goderpad-runner + environment: + - REDIS_ADDR=goderpad-redis:6379 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + depends_on: + - goderpad-redis goderpad-frontend: build: @@ -24,7 +46,7 @@ services: environment: - VITE_API_URL=http://localhost:7778 - VITE_WS_URL=ws://localhost:7778 - + goderpad-nginx: image: nginx:alpine volumes: diff --git a/docker-compose.yml b/docker-compose.yml index 74c4588..ea4964a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,31 @@ services: + goderpad-redis: + image: redis:7-alpine + container_name: goderpad-redis + goderpad-backend: build: context: . dockerfile: Dockerfile.backend container_name: goderpad-backend + environment: + - REDIS_ADDR=goderpad-redis:6379 volumes: - ./past:/app/past + depends_on: + - goderpad-redis + + goderpad-runner: + build: + context: . + dockerfile: Dockerfile.runner + container_name: goderpad-runner + environment: + - REDIS_ADDR=goderpad-redis:6379 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + depends_on: + - goderpad-redis goderpad-nginx: build: diff --git a/execution/execution.go b/execution/execution.go new file mode 100644 index 0000000..869ad19 --- /dev/null +++ b/execution/execution.go @@ -0,0 +1,126 @@ +package execution + +import ( + "bytes" + "context" + "fmt" + "log" + "os/exec" + "strings" + "time" + + "goderpad/config" +) + +const ExecutionTimeout = 20 * time.Second + +type LangConfig struct { + Image string + Dockerfile string + Filename string + Command string +} + +var LanguageConfigs = map[string]LangConfig{ + "python": { + Image: "goderpad-python", + Dockerfile: "docker/python.Dockerfile", + Filename: "main.py", + Command: "cat > /tmp/main.py && python3 /tmp/main.py", + }, + "javascript": { + Image: "goderpad-javascript", + Dockerfile: "docker/javascript.Dockerfile", + Filename: "main.js", + Command: "cat > /tmp/main.js && node /tmp/main.js", + }, + "c++": { + Image: "goderpad-cpp", + Dockerfile: "docker/cpp.Dockerfile", + Filename: "main.cpp", + Command: "cat > /tmp/main.cpp && g++ -o /tmp/main /tmp/main.cpp && /tmp/main", + }, + "java": { + Image: "goderpad-java", + Dockerfile: "docker/java.Dockerfile", + Filename: "Main.java", + Command: "cat > /tmp/Main.java && javac -d /tmp /tmp/Main.java && java -cp /tmp Main", + }, +} + +type Result struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + Code int `json:"code"` +} + +// BuildImages builds each language sandbox image from its Dockerfile. +func BuildImages() { + for lang, cfg := range LanguageConfigs { + log.Printf("building execution image for %s...", lang) + cmd := exec.Command(config.GetDockerBinaryPath(), "build", "-t", cfg.Image, "-f", cfg.Dockerfile, ".") + if out, err := cmd.CombinedOutput(); err != nil { + log.Printf("error building %s image: %v\n%s", lang, err, out) + } else { + log.Printf("built execution image for %s", lang) + } + } +} + +// RunCode executes user code in a sandboxed Docker container. +// Code is piped via stdin to avoid volume mounts, which don't work +// when the runner itself is inside a container (Docker-in-Docker). +func RunCode(language, code string) (*Result, error) { + cfg, ok := LanguageConfigs[language] + if !ok { + return nil, fmt.Errorf("unsupported language: %s", language) + } + + ctx, cancel := context.WithTimeout(context.Background(), ExecutionTimeout) + defer cancel() + + args := []string{ + "run", "--rm", "-i", + "--network=none", + "--memory=256m", + "--memory-swap=256m", + "--cpus=0.25", + "--pids-limit=64", + "--read-only", + "--tmpfs=/tmp:exec,size=32m", + cfg.Image, + "sh", "-c", cfg.Command, + } + + cmd := exec.CommandContext(ctx, config.GetDockerBinaryPath(), args...) + cmd.Stdin = strings.NewReader(code) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + + if ctx.Err() == context.DeadlineExceeded { + return &Result{ + Stdout: stdout.String(), + Stderr: "execution timed out (20s limit)", + Code: -1, + }, nil + } + + exitCode := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + return nil, fmt.Errorf("docker unavailable — is it installed and running? (%v)", err) + } + } + + return &Result{ + Stdout: stdout.String(), + Stderr: stderr.String(), + Code: exitCode, + }, nil +} diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx index 4cafae0..f3734f1 100644 --- a/frontend/src/components/home/HomePage.tsx +++ b/frontend/src/components/home/HomePage.tsx @@ -23,7 +23,7 @@ const REACT_CARD: TemplateCard = { const LEETCODE_CARD: TemplateCard = { name: 'leetcode interview', description: 'write code in python, c++, java, or javascript', - code: `function main() {\n console.log("Hello, World!");\n}`, + code: `def main():\n print("Hello, World!")\n\nmain()`, interviewType: 'leetcode', }; @@ -59,7 +59,7 @@ function HomePage() { const handleSelectTemplate = async (template: TemplateCard) => { const finalRoomName = roomName || template.name; - const language = template.interviewType === 'leetcode' ? 'javascript' : 'react'; + const language = template.interviewType === 'leetcode' ? 'python' : 'react'; const response = await createRoom(userId, userName, finalRoomName, language, template.code); if (response.ok) { const roomId = response.data.roomId; diff --git a/frontend/src/components/room/CodeEditor.tsx b/frontend/src/components/room/CodeEditor.tsx index 11ce94c..fd17189 100644 --- a/frontend/src/components/room/CodeEditor.tsx +++ b/frontend/src/components/room/CodeEditor.tsx @@ -2,7 +2,7 @@ import Editor from '@monaco-editor/react'; import { useState, useRef, useContext, useEffect, useCallback } from 'react'; import { DarkModeContext, UserContext } from '../../App'; import { SandpackProvider, SandpackPreview } from '@codesandbox/sandpack-react'; -import { switchLanguage, executeCode } from '../../api/api'; +import { switchLanguage } from '../../api/api'; type Language = 'react' | 'javascript' | 'python' | 'cpp' | 'java'; export type InterviewType = 'react' | 'leetcode'; @@ -48,7 +48,7 @@ interface CodeEditorProps { function CodeEditor({ code, setCode, ws, roomId, interviewType, users }: CodeEditorProps) { const { isDark } = useContext(DarkModeContext); const { userId } = useContext(UserContext); - const [language, setLanguage] = useState(interviewType === 'leetcode' ? 'javascript' : 'react'); + const [language, setLanguage] = useState(interviewType === 'leetcode' ? 'python' : 'react'); const [langDropdownOpen, setLangDropdownOpen] = useState(false); const [runOutput, setRunOutput] = useState(null); const [isRunning, setIsRunning] = useState(false); @@ -374,22 +374,43 @@ function CodeEditor({ code, setCode, ws, roomId, interviewType, users }: CodeEdi }; }, [users, userId, visibleLabels]); - const runCode = async () => { + // Listen for execute_result messages on the WebSocket + useEffect(() => { + if (!ws) return; + const handler = (event: MessageEvent) => { + const message = JSON.parse(event.data); + if (message.type === 'execute_result') { + setIsRunning(false); + const payload = message.payload; + if (payload.stderr && !payload.stdout && payload.code !== 0) { + setRunError(payload.stderr); + } else { + setRunOutput({ + stdout: payload.stdout, + stderr: payload.stderr, + code: payload.code, + }); + } + } + }; + ws.addEventListener('message', handler); + return () => ws.removeEventListener('message', handler); + }, [ws]); + + const runCode = () => { if (!selectedLang.execLang) return; + if (!ws || ws.readyState !== WebSocket.OPEN) return; setIsRunning(true); setRunOutput(null); setRunError(null); - const data = await executeCode(selectedLang.execLang, code); - setIsRunning(false); - if (data.ok === false) { - setRunError(data.error || 'failed to run code'); - } else { - setRunOutput({ - stdout: data.stdout, - stderr: data.stderr, - code: data.code, - }); - } + ws.send(JSON.stringify({ + userId, + type: 'execute_request', + payload: { + language: selectedLang.execLang, + code: code, + } + })); }; const availableLanguages = interviewType === 'leetcode' diff --git a/go.mod b/go.mod index a342fec..8aacf00 100644 --- a/go.mod +++ b/go.mod @@ -14,8 +14,9 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -36,8 +37,10 @@ require ( github.com/prometheus/procfs v0.12.0 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect + github.com/redis/go-redis/v9 v9.18.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/atomic v1.11.0 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect golang.org/x/crypto v0.40.0 // indirect diff --git a/go.sum b/go.sum index 3949b0c..288351a 100644 --- a/go.sum +++ b/go.sum @@ -6,12 +6,16 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= @@ -70,6 +74,8 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -85,6 +91,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= diff --git a/handlers/execute.go b/handlers/execute.go index 8007b53..d490bde 100644 --- a/handlers/execute.go +++ b/handlers/execute.go @@ -1,86 +1,22 @@ package handlers import ( - "bytes" - "context" - "fmt" - "log" "net/http" - "os" - "os/exec" - "path/filepath" - "time" "github.com/gin-gonic/gin" "goderpad/config" + "goderpad/execution" ) -const executionTimeout = 10 * time.Second - -type langConfig struct { - image string - dockerfile string - filename string - command string -} - -var languageConfigs = map[string]langConfig{ - "python": { - image: "goderpad-python", - dockerfile: "docker/python.Dockerfile", - filename: "main.py", - command: "python3 /code/main.py", - }, - "javascript": { - image: "goderpad-javascript", - dockerfile: "docker/javascript.Dockerfile", - filename: "main.js", - command: "node /code/main.js", - }, - "c++": { - image: "goderpad-cpp", - dockerfile: "docker/cpp.Dockerfile", - filename: "main.cpp", - command: "g++ -o /tmp/main /code/main.cpp && /tmp/main", - }, - "java": { - image: "goderpad-java", - dockerfile: "docker/java.Dockerfile", - filename: "Main.java", - command: "javac -d /tmp /code/Main.java && java -cp /tmp Main", - }, -} - -// BuildExecutionImages builds each language sandbox image from its Dockerfile. -// Run as a goroutine at startup — the /execute endpoint will return an error -// for any language whose image hasn't finished building yet. -func BuildExecutionImages() { - for lang, cfg := range languageConfigs { - log.Printf("building execution image for %s...", lang) - cmd := exec.Command(config.GetDockerBinaryPath(), "build", "-t", cfg.image, "-f", cfg.dockerfile, ".") - if out, err := cmd.CombinedOutput(); err != nil { - log.Printf("error building %s image: %v\n%s", lang, err, out) - } else { - log.Printf("built execution image for %s", lang) - } - } -} - type executeRequest struct { Language string `json:"language"` Code string `json:"code"` } -type executeResult struct { - Stdout string `json:"stdout"` - Stderr string `json:"stderr"` - Code int `json:"code"` -} - func ExecuteHandler(c *gin.Context) { if !config.GetEnableExecutionImages() { - c.JSON(http.StatusOK, executeResult{ + c.JSON(http.StatusOK, execution.Result{ Stdout: "", Stderr: "code execution is disabled! to enable, set enable_execution_images: true in config/config.yml and restart the server.", Code: -1, @@ -94,7 +30,7 @@ func ExecuteHandler(c *gin.Context) { return } - result, err := runCode(req.Language, req.Code) + result, err := execution.RunCode(req.Language, req.Code) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"ok": false, "error": err.Error()}) return @@ -102,68 +38,3 @@ func ExecuteHandler(c *gin.Context) { c.JSON(http.StatusOK, result) } - -func runCode(language, code string) (*executeResult, error) { - cfg, ok := languageConfigs[language] - if !ok { - return nil, fmt.Errorf("unsupported language: %s", language) - } - - dir, err := os.MkdirTemp("", "goderpad-*") - if err != nil { - return nil, fmt.Errorf("failed to create temp directory") - } - defer os.RemoveAll(dir) - - if err := os.WriteFile(filepath.Join(dir, cfg.filename), []byte(code), 0644); err != nil { - return nil, fmt.Errorf("failed to write code") - } - - ctx, cancel := context.WithTimeout(context.Background(), executionTimeout) - defer cancel() - - args := []string{ - "run", "--rm", - "--network=none", - "--memory=256m", - "--memory-swap=256m", - "--cpus=0.25", - "--pids-limit=64", - "--read-only", - "--tmpfs=/tmp:exec,size=32m", - "-v", dir + ":/code:ro", - cfg.image, - "sh", "-c", cfg.command, - } - - cmd := exec.CommandContext(ctx, config.GetDockerBinaryPath(), args...) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err = cmd.Run() - - if ctx.Err() == context.DeadlineExceeded { - return &executeResult{ - Stdout: stdout.String(), - Stderr: "execution timed out (10s limit)", - Code: -1, - }, nil - } - - exitCode := 0 - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - exitCode = exitErr.ExitCode() - } else { - return nil, fmt.Errorf("docker unavailable — is it installed and running? (%v)", err) - } - } - - return &executeResult{ - Stdout: stdout.String(), - Stderr: stderr.String(), - Code: exitCode, - }, nil -} diff --git a/handlers/ws.go b/handlers/ws.go index 2bde599..af6ea1e 100644 --- a/handlers/ws.go +++ b/handlers/ws.go @@ -1,14 +1,18 @@ package handlers import ( + "context" + "fmt" "log" "net/http" + "time" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" "goderpad/metrics" "goderpad/models" + "goderpad/services" ) var upgrader = websocket.Upgrader{ @@ -114,6 +118,31 @@ func readBroadcastsFromUser(user *models.User, room *models.Room) { continue } } + if msg.Type == string(models.ExecuteRequestMessageType) { + language, _ := msg.Payload["language"].(string) + code, _ := msg.Payload["code"].(string) + jobID := fmt.Sprintf("%s:%s:%d", room.RoomID, user.UserID, time.Now().UnixNano()) + job := models.ExecuteJob{ + JobID: jobID, + RoomID: room.RoomID, + UserID: user.UserID, + Language: language, + Code: code, + } + if err := services.PublishJob(context.Background(), job); err != nil { + user.Send <- models.BroadcastMessage{ + UserID: user.UserID, + Type: string(models.ExecuteResultMessageType), + Payload: map[string]any{ + "userId": user.UserID, + "stdout": "", + "stderr": "failed to queue execution: " + err.Error(), + "code": -1, + }, + } + } + continue + } room.Broadcast <- msg } } diff --git a/models/job.go b/models/job.go new file mode 100644 index 0000000..96f85d8 --- /dev/null +++ b/models/job.go @@ -0,0 +1,18 @@ +package models + +type ExecuteJob struct { + JobID string `json:"jobId"` + RoomID string `json:"roomId"` + UserID string `json:"userId"` + Language string `json:"language"` + Code string `json:"code"` +} + +type ExecuteResult struct { + JobID string `json:"jobId"` + RoomID string `json:"roomId"` + UserID string `json:"userId"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + Code int `json:"code"` +} diff --git a/models/ws.go b/models/ws.go index 8e25300..ad41d3e 100644 --- a/models/ws.go +++ b/models/ws.go @@ -9,6 +9,8 @@ const ( CodeUpdateMessageType MessageType = "code_update" SelectionUpdateMessageType MessageType = "selection_update" VisibilityChangeMessageType MessageType = "visibility_change" + ExecuteRequestMessageType MessageType = "execute_request" + ExecuteResultMessageType MessageType = "execute_result" ) type BroadcastMessage struct { diff --git a/redisclient/client.go b/redisclient/client.go new file mode 100644 index 0000000..e802fc8 --- /dev/null +++ b/redisclient/client.go @@ -0,0 +1,38 @@ +package redisclient + +import ( + "context" + "fmt" + "log" + + "github.com/redis/go-redis/v9" + + "goderpad/config" +) + +var client *redis.Client + +func Init() error { + client = redis.NewClient(&redis.Options{ + Addr: config.GetRedisAddr(), + Password: config.GetRedisPassword(), + DB: config.GetRedisDB(), + }) + + if err := client.Ping(context.Background()).Err(); err != nil { + return fmt.Errorf("failed to connect to redis: %w", err) + } + + log.Printf("connected to redis at %s", config.GetRedisAddr()) + return nil +} + +func GetClient() *redis.Client { + return client +} + +func Close() { + if client != nil { + client.Close() + } +} diff --git a/services/jobqueue.go b/services/jobqueue.go new file mode 100644 index 0000000..a6cf882 --- /dev/null +++ b/services/jobqueue.go @@ -0,0 +1,128 @@ +package services + +import ( + "context" + "encoding/json" + "log" + "os" + "time" + + "github.com/redis/go-redis/v9" + + "goderpad/models" + "goderpad/redisclient" +) + +const ( + JobsStream = "goderpad:jobs" + ResultsStream = "goderpad:results" + ServerGroup = "server" +) + +func serverConsumerName() string { + hostname, _ := os.Hostname() + return "server-" + hostname +} + +// PublishJob adds an execution job to the Redis jobs stream. +func PublishJob(ctx context.Context, job models.ExecuteJob) error { + data, err := json.Marshal(job) + if err != nil { + return err + } + + return redisclient.GetClient().XAdd(ctx, &redis.XAddArgs{ + Stream: JobsStream, + MaxLen: 1000, + Approx: true, + Values: map[string]interface{}{ + "data": string(data), + }, + }).Err() +} + +// StartResultListener consumes execution results from Redis and routes them +// back to the appropriate room's WebSocket connections. +func StartResultListener(ctx context.Context) { + rdb := redisclient.GetClient() + consumer := serverConsumerName() + + // Create consumer group, ignore error if it already exists + err := rdb.XGroupCreateMkStream(ctx, ResultsStream, ServerGroup, "0").Err() + if err != nil && err.Error() != "BUSYGROUP Consumer Group name already exists" { + log.Printf("warning: could not create results consumer group: %v", err) + } + + log.Printf("result listener started (consumer=%s)", consumer) + + for { + select { + case <-ctx.Done(): + log.Println("result listener shutting down") + return + default: + } + + streams, err := rdb.XReadGroup(ctx, &redis.XReadGroupArgs{ + Group: ServerGroup, + Consumer: consumer, + Streams: []string{ResultsStream, ">"}, + Count: 10, + Block: 5 * time.Second, + }).Result() + + if err != nil { + if err == redis.Nil || ctx.Err() != nil { + continue + } + log.Printf("error reading results stream: %v", err) + time.Sleep(1 * time.Second) + continue + } + + for _, stream := range streams { + for _, msg := range stream.Messages { + handleResultMessage(ctx, rdb, msg) + } + } + } +} + +func handleResultMessage(ctx context.Context, rdb *redis.Client, msg redis.XMessage) { + data, ok := msg.Values["data"].(string) + if !ok { + log.Printf("invalid result message: %s", msg.ID) + rdb.XAck(ctx, ResultsStream, ServerGroup, msg.ID) + return + } + + var result models.ExecuteResult + if err := json.Unmarshal([]byte(data), &result); err != nil { + log.Printf("failed to unmarshal result: %v", err) + rdb.XAck(ctx, ResultsStream, ServerGroup, msg.ID) + return + } + + hub := models.GetHub() + room, exists := hub.GetRoom(result.RoomID) + if !exists { + log.Printf("result for unknown room %s, discarding", result.RoomID) + rdb.XAck(ctx, ResultsStream, ServerGroup, msg.ID) + return + } + + // Send execute_result to ALL users in the room (including the one who triggered it). + // Using empty UserID so BroadcastToUsers won't skip anyone. + room.Broadcast <- models.BroadcastMessage{ + UserID: "", + Type: string(models.ExecuteResultMessageType), + Payload: map[string]any{ + "userId": result.UserID, + "stdout": result.Stdout, + "stderr": result.Stderr, + "code": result.Code, + }, + } + + rdb.XAck(ctx, ResultsStream, ServerGroup, msg.ID) +} diff --git a/utils/defaultCode.go b/utils/defaultCode.go index 7b8a735..1b05ec6 100644 --- a/utils/defaultCode.go +++ b/utils/defaultCode.go @@ -2,13 +2,13 @@ package utils const DEFAULT_REACT_CODE = "import React from 'react';\n\nfunction App() {\n return (\n
\n

Hello, World!

\n
\n );\n}\nexport default App;" -const DEFAULT_PYTHON_CODE = "def main():\n print(\"Hello, World!\")" +const DEFAULT_PYTHON_CODE = "def main():\n print(\"Hello, World!\")\n main()" const DEFAULT_JAVA_CODE = "public class Main {\n public static void main(String[] args) {\n System.out.println(\"Hello, World!\");\n }\n}" const DEFAULT_CPP_CODE = "#include \n\nint main() {\n std::cout << \"Hello, World!\" << std::endl;\n return 0;\n}" -const DEFAULT_JAVASCRIPT_CODE = "function main() {\n console.log(\"Hello, World!\");\n}" +const DEFAULT_JAVASCRIPT_CODE = "function main() {\n console.log(\"Hello, World!\");\n}\n main()" // DEFAULT_CODE kept for backwards compatibility const DEFAULT_CODE = DEFAULT_REACT_CODE From bcb1f6ebb7f510647323f79f17efb83ece7e0dcc Mon Sep 17 00:00:00 2001 From: adarshm11 Date: Mon, 30 Mar 2026 23:31:38 -0700 Subject: [PATCH 8/8] only run redis + runner if config specifies --- cmd/runner/main.go | 2 +- cmd/server/main.go | 20 ++++++++++---------- config/config.example.yml | 2 +- config/config.go | 6 +++--- docker-compose.dev.yml | 6 ++++-- docker-compose.yml | 6 ++++-- handlers/execute.go | 4 ++-- handlers/ws.go | 14 ++++++++++++++ 8 files changed, 39 insertions(+), 21 deletions(-) diff --git a/cmd/runner/main.go b/cmd/runner/main.go index cebf1b6..4cbf5a9 100644 --- a/cmd/runner/main.go +++ b/cmd/runner/main.go @@ -36,7 +36,7 @@ func main() { defer redisclient.Close() // Build sandbox Docker images before accepting jobs - if config.GetEnableExecutionImages() { + if config.GetEnableCodeExecution() { execution.BuildImages() } diff --git a/cmd/server/main.go b/cmd/server/main.go index 5e883cb..21b3aeb 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -54,19 +54,19 @@ func main() { r.GET("/metrics", gin.WrapH(promhttp.Handler())) go services.DeleteRoomSaves() - if config.GetEnableExecutionImages() { + + if config.GetEnableCodeExecution() { go execution.BuildImages() - } - // Initialize Redis and start result listener - if err := redisclient.Init(); err != nil { - log.Fatalf("Failed to connect to Redis: %v", err) - } - defer redisclient.Close() + if err := redisclient.Init(); err != nil { + log.Fatalf("Failed to connect to Redis: %v", err) + } + defer redisclient.Close() - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() - go services.StartResultListener(ctx) + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + go services.StartResultListener(ctx) + } r.Run(":" + config.GetPort()) } diff --git a/config/config.example.yml b/config/config.example.yml index 6bb97a4..9df7319 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -1,7 +1,7 @@ server: port: YOUR_PORT_HERE api_key: YOUR_API_KEY_HERE - enable_execution_images: false + enable_code_execution: false docker_binary_path: "" # leave empty to use "docker" from $PATH, or set full path e.g. /usr/local/bin/docker allowed_origins: - "http://localhost:7777" diff --git a/config/config.go b/config/config.go index abea2a6..668634c 100644 --- a/config/config.go +++ b/config/config.go @@ -22,7 +22,7 @@ type ServerConfig struct { Port string `yaml:"port"` APIKey string `yaml:"api_key"` AllowedOrigins []string `yaml:"allowed_origins"` - EnableExecutionImages bool `yaml:"enable_execution_images"` + EnableCodeExecution bool `yaml:"enable_code_execution"` DockerBinaryPath string `yaml:"docker_binary_path"` } @@ -55,8 +55,8 @@ func GetAllowedOrigins() []string { return AppConfig.Server.AllowedOrigins } -func GetEnableExecutionImages() bool { - return AppConfig.Server.EnableExecutionImages +func GetEnableCodeExecution() bool { + return AppConfig.Server.EnableCodeExecution } func GetRedisAddr() string { diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 8b00b5e..9d328ce 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -4,6 +4,8 @@ services: container_name: goderpad-redis ports: - "6379:6379" + profiles: + - code-execution goderpad-backend: build: @@ -16,8 +18,6 @@ services: - REDIS_ADDR=goderpad-redis:6379 volumes: - ./past:/app/past - depends_on: - - goderpad-redis goderpad-runner: build: @@ -30,6 +30,8 @@ services: - /var/run/docker.sock:/var/run/docker.sock depends_on: - goderpad-redis + profiles: + - code-execution goderpad-frontend: build: diff --git a/docker-compose.yml b/docker-compose.yml index ea4964a..e70d344 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,8 @@ services: goderpad-redis: image: redis:7-alpine container_name: goderpad-redis + profiles: + - code-execution goderpad-backend: build: @@ -12,8 +14,6 @@ services: - REDIS_ADDR=goderpad-redis:6379 volumes: - ./past:/app/past - depends_on: - - goderpad-redis goderpad-runner: build: @@ -26,6 +26,8 @@ services: - /var/run/docker.sock:/var/run/docker.sock depends_on: - goderpad-redis + profiles: + - code-execution goderpad-nginx: build: diff --git a/handlers/execute.go b/handlers/execute.go index d490bde..6dc5441 100644 --- a/handlers/execute.go +++ b/handlers/execute.go @@ -15,10 +15,10 @@ type executeRequest struct { } func ExecuteHandler(c *gin.Context) { - if !config.GetEnableExecutionImages() { + if !config.GetEnableCodeExecution() { c.JSON(http.StatusOK, execution.Result{ Stdout: "", - Stderr: "code execution is disabled! to enable, set enable_execution_images: true in config/config.yml and restart the server.", + Stderr: "code execution is disabled! to enable, set enable_code_execution: true in config/config.yml and restart the server.", Code: -1, }) return diff --git a/handlers/ws.go b/handlers/ws.go index af6ea1e..d56bdf3 100644 --- a/handlers/ws.go +++ b/handlers/ws.go @@ -10,6 +10,7 @@ import ( "github.com/gin-gonic/gin" "github.com/gorilla/websocket" + "goderpad/config" "goderpad/metrics" "goderpad/models" "goderpad/services" @@ -119,6 +120,19 @@ func readBroadcastsFromUser(user *models.User, room *models.Room) { } } if msg.Type == string(models.ExecuteRequestMessageType) { + if !config.GetEnableCodeExecution() { + user.Send <- models.BroadcastMessage{ + UserID: user.UserID, + Type: string(models.ExecuteResultMessageType), + Payload: map[string]any{ + "userId": user.UserID, + "stdout": "", + "stderr": "code execution is disabled! to enable, set enable_code_execution: true in config/config.yml and restart the server.", + "code": -1, + }, + } + continue + } language, _ := msg.Payload["language"].(string) code, _ := msg.Payload["code"].(string) jobID := fmt.Sprintf("%s:%s:%d", room.RoomID, user.UserID, time.Now().UnixNano())